Compare commits

...
34 Commits
Author SHA1 Message Date
Wxw-Gu 77adc744e0 feat: 设置功能 2026-07-30 09:49:56 +08:00
Wxw-Gu 0adb064681 refactor: 代码拆分 2026-07-29 10:50:27 +08:00
Wxw-Gu 8e40487e08 docs: 修改README 2026-07-28 16:50:13 +08:00
Wxw-Gu d0ae9e6019 perf: 优化图片消息后台加载与解密缓存
- 缩略图优先展示并在后台准备原图
- 增加图片请求去重与受控并发队列
- 缓存图片路径、账号目录和解密结果
2026-07-28 16:11:59 +08:00
Wxw-Gu 49684f3365 feat: 完善 AI 智能检索与定位
新增 AI 查询规划和主题变体多轮检索
修复无结果时回退全量消息导致的错误结论
优化目标成员优先级和大数据量消息匹配性能
新增检索诊断日志、任务中心和持久化缓存
支持证据按时间定位档案并闪烁提示
2026-07-28 15:52:31 +08:00
Wxw-Gu 33aaf4d558 feat: 修改缓存加载逻辑 2026-07-28 09:56:50 +08:00
电摇小子 5ccabb3898 feat: 完善微信小程序与红包消息解析
- 新增小程序和红包结构化消息及专用卡片
- 修复嵌套类型、拍一拍和表情包误判
- 补充常见 AppMsg 类型分类
2026-07-28 03:20:11 +08:00
电摇小子 7db845ac7e feat: 优化聊天记录缓存、虚拟分页与群聊媒体展示
- 使用持久化缓存加速启动并异步读取 WCDB 消息
- 修复空群缓存、头像和群成员名称丢失问题
- 修复引用图片缩略图、虚拟卸载缓存和图片预览
- 修正引用消息发送者显示为群 ID 的问题
2026-07-28 02:45:20 +08:00
电摇小子 9348c5ce4a feat: 修复导出任务状态并优化聊天记录导出体验 2026-07-27 22:06:08 +08:00
Wxw-Gu f626d89a1a feat: 提交 2026-07-27 20:34:57 +08:00
Wxw-Gu 95e517eca5 feat: 表情包优化 2026-07-27 17:55:11 +08:00
Wxw-Gu e93e4554d7 feat: 导出功能 2026-07-27 17:43:36 +08:00
Wxw-Gu 9ae0c6cc47 chore: 删除无用代码 2026-07-27 10:13:13 +08:00
Wxw-Gu 59e609b08a Merge branch 'develop' 2026-07-27 09:42:25 +08:00
Wxw-Gu 9de7dcde3b chore: 提升版本号 2026-07-27 09:41:49 +08:00
电摇小子 cb3c2855c0 fix(paths): 同步 imageKeyRoot 与 dbRoot,仅识别 xwechat_files
之前的状态面板(chat.getCurrentAccountRoot())和自动获取图片密钥(settings.imageKeyRoot)
走两个完全不同的来源,二者漂移导致状态显示 D 盘但实际扫描到 C 盘。

index.ts:
- key:autoGetImageKey 优先级调整为 chat.getCurrentAccountRoot() → self.accountRoot →
  settings.imageKeyRoot → settings.dbRoot,让运行时识别的目录永远最优先。
- db:init:保存 dbRoot 时同步更新 imageKeyRoot,避免 settings 缓存漂移。
- db:reopenWithRoot:用户手动切换根目录时同步更新 imageKeyRoot。

settings-store.ts:
- loadSettings:dbRoot 修正时同步 imageKeyRoot;若 imageKeyRoot 指向旧路径或不存在,
  回退到 dbRoot。
- 新增 redirectLegacyWeChatFilesToXwechat():当 settings 里的 dbRoot/imageKeyRoot 仍指向
  V3 时代的 "Documents\WeChat Files" 路径时,自动重定向到同目录下的 xwechat_files(如果存在)。
  老用户升级时无需手动迁移。

wcdb4-client / windows-db-root-discovery / key-service-win:
- 候选目录列表只保留 xwechat_files 形态(Documents\xwechat_files、
  AppData\Roaming\Tencent\xwechat_files、macOS 的 Library/Containers/.../xwechat_files),
  移除 V3 时代的 "WeChat Files" 候选。
- DB_ROOT_NAMES 只包含 xwechat_files,自动扫描也只认 V4 路径。

V3 数据(V1 头 dat / WeChat Files 目录结构)如果用户机器上还存在,仅作为残留,
不会被 WechatExplorer 当成有效数据源。
2026-07-26 20:46:51 +08:00
电摇小子 dca88e5db3 fix(image-decrypt): 仅支持 WeChat 4.0,移除 V3 兜底 + 模板诊断
WeChat 4.0 dat 文件头为 07 08 56 32 08 07。V3 / 老版本(V1 头)不在支持范围。

image-decrypt-service:
- 移除 defaultV1AesKey ('cfcd208495d565ef') 字段。
- 移除 if (version === 1) 的 V1 默认 key 分支。
- getDatVersion 只识别 V2 头;其他返回 0 走 unsupported。
- 注释说明 decryptDatV4 方法名里的 V4 是历史命名,跟协议版本无关。

key-service-win._findTemplateData / autoGetImageKeyByMemoryScan:
- 统计扫描诊断信息:搜索到的 _t.dat 数量、V2 头数量、非 V2 数量、扫描根目录。
- 把模糊的「未找到 V2 模板文件」拆成三种具体提示:
  1) 目录下完全没有 _t.dat → 让用户先在微信里点开图片大图(缩略图未生成)。
  2) 有 _t.dat 但都不是 V2 头 → 让用户查看更多图片。
  3) 有 V2 但没有有效长度 → 让用户查看更多图片。
- 每条都附带实际扫描根目录,便于远程排查时一眼看出路径是否正确。
2026-07-26 20:46:34 +08:00
电摇小子 1a9f27488b refactor(image-decrypt): 设置面板 UI 重构 + 测试步骤联动
页面拆分两个目录语义:
- 上方「图片解密状态」的「图片资源目录」始终等于 chat 实时识别的默认目录(chat.getCurrentAccountRoot()),
  不再被用户输入覆盖。
- 下方「图片密钥管理」移除「图片资源目录」输入框,只保留 XOR / AES 两个 Key。
  该字段之前会被保存但实际不影响图片解密(数据库查询走默认根目录),保留只会让用户困惑。

UI 改动:
- ImageKeyConfiguration:移除 resourceRoot 输入框,edit() 签名收窄为 xorKey | aesKey。
- ImageTestSection:测试结果由 3 列横排改为步骤条样式(pending / ok / fail / skipped),
  任意一步 ✗ 后续步骤自动标记「跳过」并灰掉,前端语义上变成「要么都成功要么都失败」。
- AutoDetectImageKeySection:加一行小字提示「仅支持 WeChat 4.0,V3 及以下无法解析」。

后端改动:
- testImageDecryption:解密失败时把 fileFound/decrypted/readable 统一置 false,
  拆出「解密成功但不可读」中间态,让 UI 步骤联动准确反映。
- inspectImageDecryptionStatus:accountRoot 兜底改为 getCurrentAccountRoot() || config.resourceRoot,
  让状态面板始终显示当前识别到的默认目录。
- sanitizeImageError:把 'no_image_message' / '300' / 'unsupported' / 'dat version' 关键字翻译成具体提示。
- useImageDecryptionController.autoDetect:错误信息原文透传,不再走 sanitizeImageError(后者会
  把扫描阶段的「未找到 V2 模板文件」/「60 秒未找到 AES 密钥」等归类成「无法解析媒体文件」,掩盖真因)。
- image-key-config-service.save():不再写回 imageKeyRoot,下方输入框只用于校验,不再落盘。
2026-07-26 20:46:20 +08:00
电摇小子 3b64e18b5e chore(security): 移除 AI 内置 Key fallback + 修正默认模型名
发布版本不再自动从环境变量读取 VITE_DEEPSEEK_API_KEY 创建默认 DeepSeek provider。
ensureEnvironmentMigration() 改为 no-op(保留方法作为占位)。
用户首次启动必须在「设置 → AI 模型」手动配置 API Key。

migrateLegacy() 保留:用于把用户自己之前存在 localStorage 的旧配置迁移到加密存储,
与内置 Key 是两回事。

附带修正 .env.example 默认模型名:deepseek-v4-flash 是已弃用/不存在的标识符,
DeepSeek 官方未发布此模型,统一改为 deepseek-chat。
2026-07-26 20:45:53 +08:00
Wxw-Gu 554dccdb21 Merge branch 'develop' 2026-07-24 16:36:11 +08:00
Wxw-Gu c674dcbc4a docs: 更新文档 2026-07-24 16:19:27 +08:00
Wxw-Gu 0f65b96da1 docs: 修改二维码 2026-07-24 14:46:17 +08:00
Wxw-Gu 8488e81bb5 feat: 支持微信视频消息解析与播放
支持视频 XML 解析、本地文件映射与拖动播放。

修复源码乱码注释并统一 UTF-8 编码。
2026-07-24 14:16:34 +08:00
Wxw-Gu 430a36333b docs: 修改Readme 2026-07-24 11:28:10 +08:00
Wxw-Gu 474250c6c7 feat: 添加微信消息防撤回功能 2026-07-24 11:27:44 +08:00
Wxw-Gu 794880d389 chore: 提升版本 2026-07-23 16:25:08 +08:00
Wxw-Gu d0ceeceb68 fix: 容错修复群聊日报 JSON, 图片样式问题 2026-07-23 16:20:45 +08:00
Wxw-Gu 62f729d281 chore: 添加应用诊断日志 2026-07-23 16:09:20 +08:00
Wxw-Gu 0d544275c0 chore: 删除图片 2026-07-17 14:46:11 +08:00
Wxw-Gu a91b4d7a56 docs: 添加交流二维码并升级版本 2026-07-17 14:44:50 +08:00
Wxw-Gu f9b567fba2 feat: 重构数据库登录与路径发现 2026-07-17 14:35:53 +08:00
Wxw-Gu 1f693aa3d7 perf: 优化聊天渲染与后台任务性能 2026-07-17 11:37:02 +08:00
Wxw-Gu 8e1e166856 merge: 合并日报名称与图片识别修复 2026-07-15 21:18:02 +08:00
Wxw-Gu 1275c512c4 fix: 修复日报成员名称与图片识别兼容性 2026-07-15 21:17:54 +08:00
120 changed files with 20337 additions and 8259 deletions
+4 -1
View File
@@ -6,9 +6,12 @@ VITE_DB_KEY=
VITE_AUTO_LOGIN=false
# AI API Configuration (Optional, can be entered in UI)
# 注意:发布版本不再自动读取以下环境变量。
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
# 然后在「设置 → AI 模型」里手动完成"添加供应商"流程。
VITE_DEEPSEEK_API_KEY=
VITE_AI_BASE_URL=https://api.deepseek.com
VITE_AI_MODEL=deepseek-v4-flash
VITE_AI_MODEL=deepseek-chat
# Message types to filter out (comma separated). Empty means show all message types.
VITE_FILTER_MSG_TYPES=
+3
View File
@@ -13,3 +13,6 @@ docs/design/
docs/ui-redesign-plan.md
docs/ui-redesign-spec.md
AGENTS.md
findings.md
progress.md
task_plan.md
+35 -48
View File
@@ -1,7 +1,7 @@
# WechatExplorer
macOS / Windows 微信聊天记录查看AI 一键生成群聊总结
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
macOS / Windows 微信聊天记录查看AI 分析工具
是一个基于 Electron + React + TypeScript 开发的本地微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供 AI 智能检索、群聊总结和多格式聊天记录导出功能。
## 项目说明
@@ -9,12 +9,13 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
> 当前版本:`v2.1.0`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
> macOS 支持相对稳定;Windows 已初步支持 但因聊天记录大/机械硬盘等问题 会有所卡顿,仍在持续兼容不同微信版本与本地目录结构。
## ✨ 功能特性
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
- **全局搜索**: 快速搜索聊天内容
- **AI 智能检索**: 支持全局搜索、指定会话搜索和自然语言提问,帮助快速定位聊天主题与相关证据
- **消息防撤回**: 可在设置中开启,归档并高亮查看对方已撤回消息。
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
@@ -28,7 +29,8 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云/关键词**: 可视化展示群聊关键词。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)
- **数据导出**: 支持聊天记录导出为 HTML、CSV、JSON 和 Markdown,支持按时间范围导出并打开文件所在文件夹
- **诊断日志**: 可在设置的高级选项中控制诊断日志,方便排查运行异常。
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
## 📸 预览
@@ -45,48 +47,31 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
### 检索界面
<img src="./public/ai-search.png" alt="检索页面" />
### 本地 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)
### [👉 WechatExplorer 完整使用教程](./docs/user-guide/getting-started.md)
1. 下载 Releases 中的 `xxx.dmg` 文件
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
`系统设置 -> 隐私与安全性 -> 仍要打开`
4. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥
教程包含 macOS 与 Windows 的分步截图,以及数据目录、SIP、图片解密密钥和自动获取失败的排查方法
### Windows 版本已测 4.1.9
> 微信 4.0+ 在 macOS / Windows 上已支持部分能力,目前仍在持续适配。如需其他成熟方案,也可参考 [WeFlow](https://github.com/hicccc77/WeFlow) 和 [Chatlog](https://github.com/sjzar/chatlog)。
> 微信 Windows `4.1.9.57` 下载地址:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
## 🛠️ 开发配置(可选)
1. 下载 Releases 中的 `xxx-setup.exe` 安装包
2. 双击安装,可按安装向导选择安装目录。
3. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥。
本地开发需要 Node.js(推荐 v16+)和 pnpm 7
> Windows 支持仍处于初步阶段。软件会有些卡顿, 如果自动获取密钥失败,请先确认微信客户端已登录并保持运行;不同微信安装路径、数据目录和权限环境可能仍需要继续适配。
## 🚀 快速开始
### 使用前置要求
- **微信版本**:
- 微信 4.0+: macOS / Windows 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
- **macOS 自动获取密钥**: 需要先关闭 SIP,操作方式见 [macOS 关闭 SIP 教程](./docs/mac-disable-sip.md);关闭 SIP 会降低系统安全性,使用完成后建议重新开启。
- 如无法获取本地数据库密码,则无法使用当前项目
- Node.js (推荐 v16+)
- pnpm@7
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
### 环境变量配置 (.env)
### 环境变量
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
@@ -100,20 +85,6 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
| `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 / 任何脚本** 读取已经解锁的微信聊天记录。
@@ -192,8 +163,24 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
## Star History
<a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&theme=dark&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
</picture>
</a>
## 🔗 参考致谢
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
- [WeFlow](https://github.com/hicccc77/WeFlow)
- [chatlog](https://github.com/sjzar/chatlog)
## 📱 交流与反馈
<p align="center">
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" />
</p>
+121
View File
@@ -0,0 +1,121 @@
# WechatExplorer 使用教程
本文介绍如何安装 WechatExplorer、自动获取微信数据库密钥,并完成首次连接。
## 1. 使用前准备
### 支持的版本
| 系统 | 已测试的微信版本 | 说明 |
| --- | --- | --- |
| macOS | `4.1.8.100` | 支持相对稳定;自动获取密钥前需要关闭 SIP |
| Windows | `4.1.9.57` | 已初步支持;不同安装路径和数据目录可能仍需手动调整 |
- macOS 微信下载:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
- Windows 微信下载:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
- WechatExplorer 下载:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)
> [!IMPORTANT]
> WechatExplorer 必须取得本机微信数据库密钥才能读取聊天记录。请仅处理你有权访问的数据。
### macOS:关闭 SIP
macOS 自动获取密钥前需要关闭 SIP,具体操作见 [macOS 关闭 SIP 教程](../mac-disable-sip.md)。
关闭 SIP 会降低系统安全性。建议了解风险后再操作,并在不再需要自动获取密钥时重新开启。
## 2. 安装 WechatExplorer
### macOS
1. 从 Releases 下载 `.dmg` 文件。
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
3. 如果系统提示“无法打开,因为开发者无法验证”,请前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
4. 如果系统提示应用已损坏,在终端执行:
```bash
xattr -cr "/Applications/WechatExplorer.app"
```
### Windows
1. 从 Releases 下载 `-setup.exe` 安装包。
2. 双击安装,并按安装向导完成操作。
## 3. 自动获取密钥
### 第一步:确认微信数据目录
启动 WechatExplorer 后,先检查页面中的“存储路径”是否正确。
![确认微信数据目录](./images/initial-setup.png)
Windows 当前不会扫描二级目录。如果没有正确识别微信数据,请进入“设置”,手动选择微信数据所在目录。
![Windows 自定义数据目录](./images/windows-data-path.png)
### 第二步:让微信停留在登录页面
如果微信已经登录,请先退出登录;然后重新打开微信,让它停留在未登录页面,暂时不要点击登录。
![微信未登录页面](./images/wechat-login-window.png)
### 第三步:开始获取密钥
返回 WechatExplorer,点击“自动获取密钥”。
- **Windows**:看到“Hook 注入成功”后,返回微信完成登录。
- **macOS**:系统会弹出授权提示,请输入当前 macOS 用户密码并完成授权,然后返回微信完成登录。
![macOS 授权页面](./images/macos-authorization.png)
> 点击“自动获取密钥”前,微信必须停留在登录页面。WechatExplorer 提示可以登录后,再回到微信完成登录。
### 第四步:完成连接
如果系统环境和微信版本符合要求,WechatExplorer 会自动填写数据库密钥并连接数据库。连接成功后即可查看、搜索和导出聊天记录,也可以配置 AI 服务生成群聊总结。
![密钥获取完成](./images/setup-complete.png)
## 4. 图片解密密钥
微信 4.0 及以上版本的图片通常以 `.dat` 文件存储,显示图片还需要:
- **XOR Key**:单字节十六进制值,例如 `0x40`。
- **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。
可以通过以下方式配置:
1. 使用首次连接页面的“自动获取密钥”。
2. 在“设置 → 图片解密密钥”中自动获取或手动填写。
3. 从 WeFlow 或 Chatlog 的设置中导出后手动填写。
数据库连接成功但图片无法显示时,请优先检查这两项密钥。
## 5. 常见问题
### 自动获取密钥失败
请依次确认:
1. 微信版本是否与上方已测试版本一致。
2. 点击“自动获取密钥”时,微信是否停留在未登录页面。
3. 微信数据目录是否正确;Windows 用户尤其需要检查是否多选或少选了一层目录。
4. macOS 是否已按教程关闭 SIP,并完成系统授权。
5. 微信和 WechatExplorer 是否都保持运行。
仍然失败时,可以切换到“手动输入”,粘贴从其他兼容工具中取得的数据库密钥。
### Windows 使用时卡顿
Windows 支持仍处于初步阶段,不同微信版本、安装路径、数据目录和权限环境可能存在差异。建议优先使用上方已测试的微信版本。
### 数据会上传吗?
聊天数据库在本机读取和处理。只有使用 AI 总结功能时,相关聊天内容才会按你配置的模型服务发送;是否启用以及使用哪个服务由你决定。
## 6. 下一步
- 在应用“设置”中填写兼容 OpenAI API 的模型服务和 API Key,使用 AI 总结功能。
- 在应用的 **API** 页面安装 Reader Skill,让 Codex 或 Claude Code 读取和总结本地群聊。
- 本地 API 的端点和调试方法见项目 [README](../../README.md#ai-集成本地-http-api)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

+1 -1
View File
@@ -68,4 +68,4 @@ publish:
provider: github
owner: Wxw-Gu
repo: WechatExplorer
releaseType: draft
releaseType: release
+139
View File
@@ -0,0 +1,139 @@
"use strict";
const electron = require("electron");
const preload = require("@electron-toolkit/preload");
const api = {
writeAppLog: (entry) => electron.ipcRenderer.invoke("app-log:write", entry),
getAppLogPath: () => electron.ipcRenderer.invoke("app-log:getPath"),
revealAppLog: () => electron.ipcRenderer.invoke("app-log:reveal"),
getAppUpdateState: () => electron.ipcRenderer.invoke("app-update:getState"),
checkAppUpdate: () => electron.ipcRenderer.invoke("app-update:check"),
downloadAppUpdate: () => electron.ipcRenderer.invoke("app-update:download"),
installAppUpdate: () => electron.ipcRenderer.invoke("app-update:install"),
onAppUpdateState: (callback) => {
const listener = (_event, state) => callback(state);
electron.ipcRenderer.on("app-update:state", listener);
return () => electron.ipcRenderer.removeListener("app-update:state", listener);
},
getCacheSummary: () => electron.ipcRenderer.invoke("cache:getSummary"),
clearCache: (scope) => electron.ipcRenderer.invoke("cache:clear", scope),
initDb: (key) => electron.ipcRenderer.invoke("db:init", key),
getBootstrapCache: () => electron.ipcRenderer.invoke("db:getBootstrapCache"),
getStartupCache: () => electron.ipcRenderer.invoke("db:getStartupCache"),
getContacts: (filter) => electron.ipcRenderer.invoke("db:getContacts", filter),
getContactAvatars: (usernames) => electron.ipcRenderer.invoke("db:getContactAvatars", usernames),
getCachedMessages: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessages", userMd5, startTime, endTime),
getCachedMessagePage: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessagePage", userMd5, startTime, endTime),
getMessages: (userMd5, startTime, endTime, options) => electron.ipcRenderer.invoke("db:getMessages", userMd5, startTime, endTime, options),
getGroupSnapshot: (userMd5) => electron.ipcRenderer.invoke("db:getGroupSnapshot", userMd5),
search: (keyword) => electron.ipcRenderer.invoke("db:search", keyword),
aiChat: (messages, options) => electron.ipcRenderer.invoke("ai:chat", messages, options),
listAIProviders: () => electron.ipcRenderer.invoke("ai:listProviders"),
getAIRuntimeConfig: () => electron.ipcRenderer.invoke("ai:getRuntimeConfig"),
saveAIProvider: (provider) => electron.ipcRenderer.invoke("ai:saveProvider", provider),
deleteAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:deleteProvider", providerId),
setDefaultAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:setDefaultProvider", providerId),
testAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:testProvider", providerId),
testAIVision: (request) => electron.ipcRenderer.invoke("ai:testVision", request),
migrateLegacyAIConfig: (config) => electron.ipcRenderer.invoke("ai:migrateLegacy", config),
copyImage: (base64String) => electron.ipcRenderer.invoke("copy-image", base64String),
getVoiceData: (sessionId, localId, createTime, svrId) => electron.ipcRenderer.invoke("db:getVoiceData", sessionId, localId, createTime, svrId),
parseMessage: (content, messageType) => electron.ipcRenderer.invoke("db:parseMessage", content, messageType),
getImage: (imageMd5, imageDatNameOrThumb, sessionId, options) => electron.ipcRenderer.invoke("db:getImage", imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes) => electron.ipcRenderer.invoke("db:getVideo", hashes),
getSticker: (cdnUrl, md5) => electron.ipcRenderer.invoke("db:getSticker", cdnUrl, md5),
startExport: (request) => electron.ipcRenderer.invoke("export:start", request),
cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId),
revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path),
onExportProgress: (callback) => {
const listener = (_event, progress) => callback(progress);
electron.ipcRenderer.on("export:progress", listener);
return () => electron.ipcRenderer.removeListener("export:progress", listener);
},
exportGroupReport: (request) => electron.ipcRenderer.invoke("report:export", request),
listGeneratedReports: () => electron.ipcRenderer.invoke("report:listGenerated"),
saveGeneratedReport: (request) => electron.ipcRenderer.invoke("report:saveGenerated", request),
deleteGeneratedReport: (reportId) => electron.ipcRenderer.invoke("report:deleteGenerated", reportId),
revealGroupReport: (filePath) => electron.ipcRenderer.invoke("report:reveal", filePath),
getSavedDbKey: () => electron.ipcRenderer.invoke("key:getSavedDbKey"),
getDatabaseKeyEnvironment: () => electron.ipcRenderer.invoke("key:getEnvironment"),
readDatabaseKeyClipboard: () => electron.ipcRenderer.invoke("key:readClipboardDbKey"),
autoGetDbKey: (options) => electron.ipcRenderer.invoke("key:autoGetDbKey", options),
autoGetImageKey: (options) => electron.ipcRenderer.invoke("key:autoGetImageKey", options),
getImageKeyConfig: () => electron.ipcRenderer.invoke("image:getConfig"),
getImageDecryptionStatus: () => electron.ipcRenderer.invoke("image:getStatus"),
saveImageKeyConfig: (request) => electron.ipcRenderer.invoke("image:saveConfig", request),
testImageDecryption: (request) => electron.ipcRenderer.invoke("image:testConfig", request),
clearImageKeyConfig: () => electron.ipcRenderer.invoke("image:clearConfig"),
pasteAndSaveDbKey: () => electron.ipcRenderer.invoke("key:pasteAndSaveDbKey"),
saveDbKey: (key) => electron.ipcRenderer.invoke("key:saveDbKey", key),
clearSavedDbKey: () => electron.ipcRenderer.invoke("key:clearSavedDbKey"),
onWcdbChange: (callback) => {
const listener = (_event, payload) => callback(payload);
electron.ipcRenderer.on("wcdb-change", listener);
return () => electron.ipcRenderer.removeListener("wcdb-change", listener);
},
onDbKeyStatus: (callback) => {
const listener = (_event, payload) => callback(payload);
electron.ipcRenderer.on("key:dbKeyStatus", listener);
return () => electron.ipcRenderer.removeListener("key:dbKeyStatus", listener);
},
onImageKeyStatus: (callback) => {
const listener = (_event, payload) => callback(payload);
electron.ipcRenderer.on("key:imageKeyStatus", listener);
return () => electron.ipcRenderer.removeListener("key:imageKeyStatus", listener);
},
getSettings: () => electron.ipcRenderer.invoke("settings:get"),
setSettings: (patch) => electron.ipcRenderer.invoke("settings:set", patch),
getSelf: () => electron.ipcRenderer.invoke("settings:getSelf"),
testConnection: (key, accountRoot) => electron.ipcRenderer.invoke("db:testConnection", key, accountRoot),
reopenWithRoot: (accountRoot) => electron.ipcRenderer.invoke("db:reopenWithRoot", accountRoot),
selectDbRoot: () => electron.ipcRenderer.invoke("settings:selectDbRoot"),
openAccountRoot: () => electron.ipcRenderer.invoke("settings:openAccountRoot"),
disconnectDb: (options) => electron.ipcRenderer.invoke("db:disconnect", options),
apiStatus: () => electron.ipcRenderer.invoke("api:getStatus"),
apiStart: (host, port) => electron.ipcRenderer.invoke("api:start", host, port),
apiStop: () => electron.ipcRenderer.invoke("api:stop"),
apiToggle: (enabled) => electron.ipcRenderer.invoke("api:toggle", enabled),
getReaderSkillStatus: () => electron.ipcRenderer.invoke("api:skillStatus"),
readReaderSkill: () => electron.ipcRenderer.invoke("api:readSkill"),
revealReaderSkill: () => electron.ipcRenderer.invoke("api:revealSkill"),
openReaderSkillGithub: () => electron.ipcRenderer.invoke("api:openSkillGithub"),
testLocalApiRequest: (request) => electron.ipcRenderer.invoke("api:testLocalRequest", request),
copyText: (text) => electron.ipcRenderer.invoke("api:copyText", text),
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
// ============================================================
imageListCandidates: (query) => electron.ipcRenderer.invoke("image:listCandidates", query),
imageAnalyze: (request) => electron.ipcRenderer.invoke("image:analyze", request),
getImageInsight: (imageHash) => electron.ipcRenderer.invoke("image:getInsight", imageHash),
listImageInsights: (sessionId, limit) => electron.ipcRenderer.invoke("image:listInsights", sessionId, limit),
getAgentHubStatus: () => electron.ipcRenderer.invoke("agent-hub:getStatus"),
getAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:getLogs"),
clearAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:clearLogs"),
startAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:startLogin"),
cancelAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:cancelLogin"),
reconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:reconnect"),
disconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:disconnect"),
selectAgentHubTestImage: () => electron.ipcRenderer.invoke("agent-hub:selectTestImage"),
onAgentHubStatus: (callback) => {
const listener = (_event, status) => callback(status);
electron.ipcRenderer.on("agent-hub:status", listener);
return () => electron.ipcRenderer.removeListener("agent-hub:status", listener);
},
onAgentHubLog: (callback) => {
const listener = (_event, entry) => callback(entry);
electron.ipcRenderer.on("agent-hub:log", listener);
return () => electron.ipcRenderer.removeListener("agent-hub:log", listener);
}
};
if (process.contextIsolated) {
try {
electron.contextBridge.exposeInMainWorld("electron", preload.electronAPI);
electron.contextBridge.exposeInMainWorld("api", api);
} catch (error) {
console.error(error);
}
} else {
window.electron = preload.electronAPI;
window.api = api;
}
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "wechatexplorer",
"version": "2.1.2",
"version": "2.1.5",
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
"keywords": [
"wechat",
@@ -44,11 +44,14 @@
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
},
"dependencies": {
"@koromix/koffi-win32-x64": "3.1.0",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@tanstack/react-virtual": "^3.14.6",
"electron-updater": "^6.6.2",
"fs-extra": "^11.3.2",
"fzstd": "^0.1.1",
"jsonrepair": "^3.15.0",
"koffi": "^3.1.0",
"openai": "^6.10.0",
"silk-wasm": "^3.7.1",
@@ -74,6 +77,7 @@
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sass": "^1.102.0",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
+236 -12
View File
@@ -12,6 +12,7 @@ specifiers:
'@electron-toolkit/utils': ^4.0.0
'@koromix/koffi-win32-x64': 3.1.0
'@rollup/rollup-darwin-arm64': ^4.62.2
'@tanstack/react-virtual': ^3.14.6
'@types/fs-extra': ^11.0.4
'@types/node': ^22.19.1
'@types/react': ^19.2.7
@@ -19,6 +20,7 @@ specifiers:
'@vitejs/plugin-react': ^5.1.1
electron: ^43.0.0
electron-builder: ^26.0.12
electron-updater: ^6.6.2
electron-vite: ^5.0.0
eslint: ^9.39.1
eslint-plugin-react: ^7.37.5
@@ -26,11 +28,13 @@ specifiers:
eslint-plugin-react-refresh: ^0.4.24
fs-extra: ^11.3.2
fzstd: ^0.1.1
jsonrepair: ^3.15.0
koffi: ^3.1.0
openai: ^6.10.0
prettier: ^3.7.4
react: ^19.2.1
react-dom: ^19.2.1
sass: ^1.102.0
silk-wasm: ^3.7.1
typescript: ^5.9.3
vite: ^7.2.6
@@ -40,8 +44,11 @@ dependencies:
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
electron-updater: 6.8.9
fs-extra: 11.3.2
fzstd: 0.1.1
jsonrepair: 3.15.0
koffi: 3.1.0
openai: 6.10.0
silk-wasm: 3.7.1
@@ -67,8 +74,9 @@ devDependencies:
prettier: 3.7.4
react: 19.2.1
react-dom: 19.2.1_react@19.2.1
sass: 1.102.0
typescript: 5.9.3
vite: 7.2.7_@types+node@22.19.2
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
packages:
@@ -926,6 +934,132 @@ packages:
rimraf: 3.0.2
dev: true
/@parcel/watcher-android-arm64/2.6.0:
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
dev: true
optional: true
/@parcel/watcher-darwin-arm64/2.6.0:
resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
dev: true
optional: true
/@parcel/watcher-darwin-x64/2.6.0:
resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
dev: true
optional: true
/@parcel/watcher-freebsd-x64/2.6.0:
resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
dev: true
optional: true
/@parcel/watcher-linux-arm-glibc/2.6.0:
resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
dev: true
optional: true
/@parcel/watcher-linux-arm-musl/2.6.0:
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
dev: true
optional: true
/@parcel/watcher-linux-arm64-glibc/2.6.0:
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
dev: true
optional: true
/@parcel/watcher-linux-arm64-musl/2.6.0:
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
dev: true
optional: true
/@parcel/watcher-linux-x64-glibc/2.6.0:
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
dev: true
optional: true
/@parcel/watcher-linux-x64-musl/2.6.0:
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
dev: true
optional: true
/@parcel/watcher-win32-arm64/2.6.0:
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
dev: true
optional: true
/@parcel/watcher-win32-x64/2.6.0:
resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
dev: true
optional: true
/@parcel/watcher/2.6.0:
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
engines: {node: '>= 10.0.0'}
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.5
optionalDependencies:
'@parcel/watcher-android-arm64': 2.6.0
'@parcel/watcher-darwin-arm64': 2.6.0
'@parcel/watcher-darwin-x64': 2.6.0
'@parcel/watcher-freebsd-x64': 2.6.0
'@parcel/watcher-linux-arm-glibc': 2.6.0
'@parcel/watcher-linux-arm-musl': 2.6.0
'@parcel/watcher-linux-arm64-glibc': 2.6.0
'@parcel/watcher-linux-arm64-musl': 2.6.0
'@parcel/watcher-linux-x64-glibc': 2.6.0
'@parcel/watcher-linux-x64-musl': 2.6.0
'@parcel/watcher-win32-arm64': 2.6.0
'@parcel/watcher-win32-x64': 2.6.0
dev: true
optional: true
/@pkgjs/parseargs/0.11.0:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1124,6 +1258,21 @@ packages:
defer-to-connect: 2.0.1
dev: true
/@tanstack/react-virtual/3.14.6_bokjwhiew3ov3ffvbmafuwoalq:
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
'@tanstack/virtual-core': 3.17.4
react: 19.2.1
react-dom: 19.2.1_react@19.2.1
dev: false
/@tanstack/virtual-core/3.17.4:
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
dev: false
/@tootallnate/once/2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -1410,7 +1559,7 @@ packages:
'@rolldown/pluginutils': 1.0.0-beta.53
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 7.2.7_@types+node@22.19.2
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
transitivePeerDependencies:
- supports-color
dev: true
@@ -1558,7 +1707,6 @@ packages:
/argparse/2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
dev: true
/array-buffer-byte-length/1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
@@ -1747,6 +1895,16 @@ packages:
- supports-color
dev: true
/builder-util-runtime/9.7.0:
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
engines: {node: '>=12.0.0'}
dependencies:
debug: 4.4.3
sax: 1.4.3
transitivePeerDependencies:
- supports-color
dev: false
/builder-util/26.0.11:
resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==}
dependencies:
@@ -1863,6 +2021,13 @@ packages:
supports-color: 7.2.0
dev: true
/chokidar/5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
dependencies:
readdirp: 5.0.0
dev: true
/chownr/2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
@@ -2200,6 +2365,21 @@ packages:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
dev: true
/electron-updater/6.8.9:
resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
dependencies:
builder-util-runtime: 9.7.0
fs-extra: 10.1.0
js-yaml: 4.1.1
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
semver: 7.7.3
tiny-typed-emitter: 2.1.0
transitivePeerDependencies:
- supports-color
dev: false
/electron-vite/5.0.0_vite@7.2.7:
resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2217,7 +2397,7 @@ packages:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
vite: 7.2.7_@types+node@22.19.2
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
transitivePeerDependencies:
- supports-color
dev: true
@@ -2720,7 +2900,6 @@ packages:
graceful-fs: 4.2.11
jsonfile: 6.2.0
universalify: 2.0.1
dev: true
/fs-extra/11.3.2:
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
@@ -3072,6 +3251,10 @@ packages:
engines: {node: '>= 4'}
dev: true
/immutable/5.1.9:
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
dev: true
/import-fresh/3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3381,7 +3564,6 @@ packages:
hasBin: true
dependencies:
argparse: 2.0.1
dev: true
/jsesc/3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
@@ -3414,6 +3596,11 @@ packages:
optionalDependencies:
graceful-fs: 4.2.11
/jsonrepair/3.15.0:
resolution: {integrity: sha512-wy8OTjwsJwQRnQJkKnMJJ9vcytRdBPAgIF/Hy6+s1dAj42BHMKiyL8JzEieIl3JY7idt8eyHwBWTO8mh/+mtwA==}
hasBin: true
dev: false
/jsx-ast-utils/3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@@ -3451,7 +3638,6 @@ packages:
/lazy-val/1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
dev: true
/levn/0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
@@ -3468,6 +3654,15 @@ packages:
p-locate: 5.0.0
dev: true
/lodash.escaperegexp/4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
dev: false
/lodash.isequal/4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
dev: false
/lodash.merge/4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
@@ -3718,6 +3913,11 @@ packages:
dev: true
optional: true
/node-addon-api/7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
dev: true
optional: true
/node-api-version/0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
dependencies:
@@ -3938,6 +4138,12 @@ packages:
engines: {node: '>=12'}
dev: true
/picomatch/4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
dev: true
optional: true
/plist/3.1.0:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
@@ -4037,7 +4243,6 @@ packages:
dependencies:
react: 19.2.1
scheduler: 0.27.0
dev: true
/react-is/16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -4051,7 +4256,6 @@ packages:
/react/19.2.1:
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
engines: {node: '>=0.10.0'}
dev: true
/read-binary-file-arch/1.0.6:
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
@@ -4071,6 +4275,11 @@ packages:
util-deprecate: 1.0.2
dev: true
/readdirp/5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
dev: true
/reflect.getprototypeof/1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -4228,13 +4437,23 @@ packages:
truncate-utf8-bytes: 1.0.2
dev: true
/sass/1.102.0:
resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==}
engines: {node: '>=20.19.0'}
hasBin: true
dependencies:
chokidar: 5.0.0
immutable: 5.1.9
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.6.0
dev: true
/sax/1.4.3:
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
dev: true
/scheduler/0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
dev: true
/semver/5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
@@ -4579,6 +4798,10 @@ packages:
semver: 5.7.2
dev: true
/tiny-typed-emitter/2.1.0:
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
dev: false
/tinyglobby/0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -4763,7 +4986,7 @@ packages:
dev: true
optional: true
/vite/7.2.7_@types+node@22.19.2:
/vite/7.2.7_kx7yyowhop237vgn7dp2p54shq:
resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -4809,6 +5032,7 @@ packages:
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.53.3
sass: 1.102.0
tinyglobby: 0.2.15
optionalDependencies:
fsevents: 2.3.3
Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+84
View File
@@ -0,0 +1,84 @@
import { app, shell } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import type { AppLogEntry } from '../shared/app-log'
import { isPackagedRuntime } from './runtime-mode'
const MAX_LOG_BYTES = 5 * 1024 * 1024
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
const sanitize = (value: unknown, depth = 0): unknown => {
if (depth > 4) return '[depth-limited]'
if (typeof value === 'string') {
return value
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
.slice(0, 2000)
}
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
REDACTED_KEY.test(key) ? '***' : sanitize(item, depth + 1)
])
)
}
return value
}
export class AppLogger {
private get logDir(): string {
return app.getPath('logs')
}
get logPath(): string {
return path.join(this.logDir, 'wechatexplorer.log')
}
private rotateIfNeeded(): void {
try {
if (!fs.existsSync(this.logPath) || fs.statSync(this.logPath).size < MAX_LOG_BYTES) return
const previous = `${this.logPath}.1`
if (fs.existsSync(previous)) fs.removeSync(previous)
fs.moveSync(this.logPath, previous)
} catch {
// Logging must never interrupt the application.
}
}
write(entry: AppLogEntry): void {
try {
fs.ensureDirSync(this.logDir)
this.rotateIfNeeded()
const record = {
timestamp: new Date().toISOString(),
mode: isPackagedRuntime() ? 'packaged' : 'development',
level: entry.level,
scope: String(entry.scope || 'app').slice(0, 80),
message: String(entry.message || '').slice(0, 500),
details: sanitize(entry.details || {})
}
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
if (!isPackagedRuntime()) {
const method =
entry.level === 'error'
? console.error
: entry.level === 'warn'
? console.warn
: console.log
method(`[${record.scope}] ${record.message}`, record.details)
}
} catch {
// Logging must never interrupt the application.
}
}
reveal(): void {
fs.ensureDirSync(this.logDir)
if (!fs.existsSync(this.logPath)) fs.writeFileSync(this.logPath, '', 'utf8')
shell.showItemInFolder(this.logPath)
}
}
export const appLogger = new AppLogger()
+41
View File
@@ -0,0 +1,41 @@
import type { Message } from '../shared/types'
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}`
const safe = (value: unknown): string =>
String(value ?? '').replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c
)
export function renderExportPage(name: string, messages: Message[]): string {
const body = messages
.map((m) => {
const avatar = m.img
? `<img src="${safe(m.img)}" alt="">`
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
const audio = m.voiceDataUrl
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>`
: ''
const quote =
m.contentData?.type === 'quote'
? `<div class="quote-reference"><strong>${safe(m.contentData.quotedSender || '引用消息')}</strong><span>${safe(m.contentData.quotedContent || '[引用消息]')}</span></div>`
: ''
const media =
m.exportMediaUrl && m.exportMediaType === 'image'
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="图片">`
: m.exportMediaUrl && m.exportMediaType === 'video'
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
: m.exportMediaUrl && m.exportMediaType === 'sticker'
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
: ''
const avatarMarkup =
m.exportShowAvatar === false
? ''
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
const isPat = m.contentData?.type === 'system' && m.contentData.pat
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
})
.join('')
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>`
}
+315
View File
@@ -0,0 +1,315 @@
import { app, BrowserWindow, shell } from 'electron'
import { promises as fs } from 'fs'
import { extname, join } from 'path'
import { fileURLToPath } from 'url'
import * as chat from './services/chat-service'
import type {
ExportJobProgress,
ExportMessageKind,
ExportRequest,
ExportResult
} from '../shared/export'
import type { Message } from '../shared/types'
import { VoiceService } from './voice-service'
import { renderExportPage } from './export-html-template'
import { ImageDecryptService } from './image-decrypt-service'
import { ImageKeyConfigService } from './services/image-key-config-service'
import { VideoAssetService } from './video-asset-service'
import { StickerService } from './sticker-service'
const jobs = new Set<string>()
const safeFilePart = (value: string): string =>
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
const exportStamp = (): string => {
const date = new Date()
const pad = (value: number): string => String(value).padStart(2, '0')
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
}
const imageKeys = new ImageKeyConfigService()
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
if (!match) return null
return {
extension: match[1].split('/')[1] === 'jpeg' ? 'jpg' : match[1].split('/')[1],
buffer: Buffer.from(match[2], 'base64')
}
}
const normalizeAssetExtension = (value: string): string => {
const extension = value.toLowerCase().replace(/^\./, '')
return /^(png|jpg|jpeg|webp|gif)$/.test(extension)
? extension === 'jpeg'
? 'jpg'
: extension
: 'jpg'
}
const detectAssetExtension = (buffer: Buffer): string | null => {
if (buffer.subarray(0, 3).toString('ascii') === 'GIF') return 'gif'
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
return 'png'
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg'
if (
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
)
return 'webp'
return null
}
async function readAvatarAsset(
source: string
): Promise<{ extension: string; buffer: Buffer } | null> {
const decoded = decodeDataUrl(source)
if (decoded) return { ...decoded, extension: normalizeAssetExtension(decoded.extension) }
try {
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source)
if (!response.ok) return null
const contentType = response.headers.get('content-type')?.split(';')[0].split('/')[1]
const extension = normalizeAssetExtension(contentType || extname(new URL(source).pathname))
const buffer = Buffer.from(await response.arrayBuffer())
return { extension: detectAssetExtension(buffer) || extension, buffer }
}
const path = source.startsWith('file://') ? fileURLToPath(source) : source
const buffer = await fs.readFile(path)
return {
extension: detectAssetExtension(buffer) || normalizeAssetExtension(extname(path)),
buffer
}
} catch {
return null
}
}
const kindOf = (message: Message): ExportMessageKind => {
const type = message.contentData?.type
if (type === 'system' && message.contentData?.pat) return 'text'
if (
type === 'image' ||
type === 'video' ||
type === 'voice' ||
type === 'sticker' ||
type === 'share' ||
type === 'location' ||
type === 'system'
)
return type
if (message.type === '图片') return 'image'
if (message.type === '视频') return 'video'
if (message.type === '语音') return 'voice'
if (message.type === '表情包') return 'sticker'
return 'text'
}
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
if (format === 'html') return renderExportPage(name, messages)
if (format === 'json')
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
if (format === 'markdown')
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}`
return [
'时间,发送者,类型,内容',
...messages.map((m) =>
[m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',')
)
].join('\n')
}
export async function runExport(request: ExportRequest, win: BrowserWindow): Promise<ExportResult> {
jobs.add(request.jobId)
const send = (p: ExportJobProgress): void => {
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
}
try {
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
await new Promise<void>((resolve) => setImmediate(resolve))
const messages = chat
.listMessages(request.userMd5, request.startTime, request.endTime)
.filter((m) => request.kinds.includes(kindOf(m)))
for (const message of messages) {
message.exportShowAvatar = request.includeAvatars !== false
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
if (mappedName) message.name = mappedName
}
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
if (!jobs.has(request.jobId)) {
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
return { success: false, error: '已取消' }
}
send({
jobId: request.jobId,
phase: 'writing',
processed: 0,
total: messages.length,
percent: 15
})
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
await fs.mkdir(root, { recursive: true })
const ext = request.format === 'markdown' ? 'md' : request.format
const outputFolder = `${safeFilePart(request.outputName)}_${exportStamp()}`
const outputDir = join(root, outputFolder)
const outputPath =
request.format === 'html'
? join(outputDir, 'index.html')
: join(root, `${outputFolder}.${ext}`)
if (request.format === 'html') {
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
const client = chat.getChatDb()?.getWcdb4Client()
const avatarUsernames = Array.from(
new Set(
messages
.map((message) => message.senderId)
.filter((value): value is string => Boolean(value))
)
)
const avatarMap =
request.includeAvatars === false
? {}
: { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) }
const imageConfig = imageKeys.getConfig()
const imageService =
client && imageConfig.aesKey
? new ImageDecryptService(imageConfig.xorKey || '0x40', imageConfig.aesKey, client)
: null
const videoService = client ? new VideoAssetService(client) : null
const stickerService = client ? new StickerService(client) : null
const exportedAvatars = new Map<string, string>()
const voiceService =
request.includeMedia && chat.getChatDb()
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
: null
if (voiceService) {
for (const [index, message] of messages.entries()) {
if (
kindOf(message) !== 'voice' ||
!message.sessionId ||
!message.localId ||
!message.createTime
)
continue
const voice = await voiceService.resolveVoice(
message.sessionId,
message.localId,
message.createTime,
message.serverId
)
if (!voice.success || !voice.data) continue
const voiceName = `voice_${index + 1}_${message.localId}.wav`
const audioBuffer = Buffer.from(voice.data, 'base64')
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
message.voiceDataUrl = `voices/${voiceName}`
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
}
}
for (const [index, message] of messages.entries()) {
message.exportShowAvatar = request.includeAvatars !== false
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null
const avatarBuffer = resolvedAvatar?.buffer || null
const avatarExtension = resolvedAvatar?.extension || 'jpg'
if (avatarBuffer) {
const avatarKey = message.senderId || `message_${index + 1}`
let avatarName = exportedAvatars.get(avatarKey)
if (!avatarName) {
avatarName = `avatar_${exportedAvatars.size + 1}.${avatarExtension}`
await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
exportedAvatars.set(avatarKey, avatarName)
}
message.exportAvatarUrl = `avatars/${avatarName}`
}
if (!request.includeMedia || !message.contentData) {
send({
jobId: request.jobId,
phase: 'writing',
processed: index + 1,
total: messages.length,
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
})
continue
}
if (message.contentData.type === 'image' && imageService) {
const file = imageService.findImageFile(
message.contentData.md5,
message.contentData.datName,
{ allowThumbnail: true }
)
const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null
const decoded = decrypted ? decodeDataUrl(decrypted.data) : null
if (decoded) {
const name = `image_${index + 1}.${decoded.extension}`
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'image'
}
} else if (message.contentData.type === 'video' && videoService) {
const hashes = [
message.contentData.md5,
message.contentData.newMd5,
message.contentData.rawMd5
].filter((value): value is string => Boolean(value))
const resolved = videoService.resolve(hashes)
const token = resolved.url?.split('/').pop()
const source = token ? videoService.pathForToken(token) : undefined
if (source) {
const name = `video_${index + 1}.mp4`
await fs.copyFile(source, join(outputDir, 'media', name))
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'video'
}
} else if (message.contentData.type === 'sticker' && stickerService) {
const stickerSource = message.contentData.url || message.contentData.thumbUrl
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
const decoded = result.data
? decodeDataUrl(result.data)
: stickerSource
? await readAvatarAsset(stickerSource)
: null
if (decoded) {
const name = `sticker_${index + 1}.${decoded.extension}`
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'sticker'
}
}
send({
jobId: request.jobId,
phase: 'writing',
processed: index + 1,
total: messages.length,
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
})
}
} else {
send({
jobId: request.jobId,
phase: 'writing',
processed: messages.length,
total: messages.length,
percent: 90
})
}
await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8')
send({
jobId: request.jobId,
phase: 'completed',
processed: messages.length,
total: messages.length,
percent: 100,
outputPath
})
return { success: true, outputPath, messageCount: messages.length }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
return { success: false, error: message }
} finally {
jobs.delete(request.jobId)
}
}
export function cancelExport(jobId: string): void {
jobs.delete(jobId)
}
export async function revealExport(path: string): Promise<void> {
shell.showItemInFolder(path)
}
+179 -56
View File
@@ -4,12 +4,28 @@ import crypto from 'crypto'
import os from 'os'
import { Wcdb4Client } from './wcdb4-client'
export class ImageDecryptService {
private readonly defaultV1AesKey = 'cfcd208495d565ef'
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
const imageDecryptLog = (...args: unknown[]): void => {
if (imageDecryptDebugEnabled) console.log(...args)
}
type DecodedImage = {
data: string
filePath: string
isThumbnail: boolean
}
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
export class ImageDecryptService {
private xorKey: number = 0
private aesKey: string = ''
private wcdb4Client: Wcdb4Client | null = null
private accountDirResolved = false
private cachedAccountDir: string | null = null
private imagePathCache = new Map<string, string>()
private decodedImageCache = new Map<string, DecodedImage>()
private decodedImageCacheBytes = 0
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
// 解析 XOR Key (支持 0x40 或 64 格式)
@@ -29,9 +45,13 @@ export class ImageDecryptService {
* 获取账号目录
*/
private getAccountDir(): string | null {
if (this.accountDirResolved) return this.cachedAccountDir
this.accountDirResolved = true
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
return wcdbAccountRoot
this.cachedAccountDir = wcdbAccountRoot
return this.cachedAccountDir
}
const homeDir = os.homedir()
@@ -41,7 +61,7 @@ export class ImageDecryptService {
)
if (!existsSync(accountRoot)) {
console.log('[ImageDecrypt] account root not found:', accountRoot)
imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot)
return null
}
@@ -61,12 +81,13 @@ export class ImageDecryptService {
.sort((a, b) => b.mtime - a.mtime)
if (accounts.length === 0) {
console.log('[ImageDecrypt] no accounts found')
imageDecryptLog('[ImageDecrypt] no accounts found')
return null
}
// 返回最新的账号目录
return join(accountRoot, accounts[0].name)
this.cachedAccountDir = join(accountRoot, accounts[0].name)
return this.cachedAccountDir
}
/**
@@ -75,15 +96,33 @@ export class ImageDecryptService {
findImageFile(
md5?: string,
imageDatName?: string,
options?: { allowThumbnail?: boolean }
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: 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:', {
const pathCacheKey = [
normalizedMd5,
normalizedDatName,
allowThumbnail ? 'thumb' : 'original',
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
options?.accountDir || ''
].join('|')
const cachedPath = this.imagePathCache.get(pathCacheKey)
if (cachedPath && existsSync(cachedPath)) return cachedPath
const rememberPath = (path: string | null): string | null => {
if (path) this.imagePathCache.set(pathCacheKey, path)
return path
}
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
const accountDir =
options?.accountDir && existsSync(options.accountDir)
? options.accountDir
: this.getAccountDir()
if (!accountDir) return null
imageDecryptLog('[ImageDecrypt] findImageFile:', {
md5: normalizedMd5,
imageDatName: normalizedDatName,
accountDir,
@@ -94,10 +133,14 @@ export class ImageDecryptService {
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)
const selected = this.getPreferredDatVariantPath(
fullPath,
allowThumbnail,
options?.preferThumbnail
)
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
console.log('[ImageDecrypt] hardlink hit:', selected)
return selected
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
return rememberPath(selected)
}
}
}
@@ -105,29 +148,76 @@ export class ImageDecryptService {
// 尝试 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)
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
return rememberPath(
this.findImageFileInLegacyDirs(
accountDir,
normalizedMd5 || normalizedDatName,
allowThumbnail,
options?.preferThumbnail
)
)
}
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 directHit = this.fastProbabilisticSearch(
attachDir,
key,
allowThumbnail,
options?.preferThumbnail
)
if (directHit) return rememberPath(directHit)
}
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
if (legacyHit) return legacyHit
const legacyHit = this.findImageFileInLegacyDirs(
accountDir,
searchKeys[0],
allowThumbnail,
options?.preferThumbnail
)
if (legacyHit) return rememberPath(legacyHit)
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
return null
}
getCachedDecodedImage(key: string): DecodedImage | null {
const cached = this.decodedImageCache.get(key)
if (!cached) return null
this.decodedImageCache.delete(key)
this.decodedImageCache.set(key, cached)
return cached
}
cacheDecodedImage(key: string, image: DecodedImage): void {
const size = image.data.length * 2
const previous = this.decodedImageCache.get(key)
if (previous) {
this.decodedImageCacheBytes -= previous.data.length * 2
this.decodedImageCache.delete(key)
}
this.decodedImageCache.set(key, image)
this.decodedImageCacheBytes += size
while (
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
this.decodedImageCache.size > 1
) {
const oldestKey = this.decodedImageCache.keys().next().value
if (!oldestKey) break
const oldest = this.decodedImageCache.get(oldestKey)
this.decodedImageCache.delete(oldestKey)
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
}
}
private fastProbabilisticSearch(
attachDir: string,
datName: string,
allowThumbnail = true
allowThumbnail = true,
preferThumbnail = false
): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
@@ -144,9 +234,9 @@ export class ImageDecryptService {
join(attachDir, dir1, dir2, 'Image', variant),
join(attachDir, dir1, dir2, 'image', variant)
]
const found = this.getLargestExistingPath(candidates, allowThumbnail)
const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
if (found) {
console.log('[ImageDecrypt] prefix path hit:', found)
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
return found
}
}
@@ -172,17 +262,18 @@ export class ImageDecryptService {
const found = this.getLargestExistingPath(
variants.map((variant) => join(imgDir, variant)),
allowThumbnail
allowThumbnail,
preferThumbnail
)
if (found) {
console.log('[ImageDecrypt] found at:', found)
imageDecryptLog('[ImageDecrypt] found at:', found)
return found
}
}
}
}
} catch (e) {
console.log('[ImageDecrypt]遍历目录失败:', e)
imageDecryptLog('[ImageDecrypt]遍历目录失败:', e)
}
return null
@@ -191,7 +282,8 @@ export class ImageDecryptService {
private findImageFileInLegacyDirs(
accountDir: string,
datName: string,
allowThumbnail = true
allowThumbnail = true,
preferThumbnail = false
): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
@@ -203,7 +295,7 @@ export class ImageDecryptService {
].filter((root) => existsSync(root))
for (const root of roots) {
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
if (found) return found
}
@@ -214,30 +306,52 @@ export class ImageDecryptService {
dir: string,
datName: string,
depth: number,
allowThumbnail = true
allowThumbnail = true,
preferThumbnail = false
): string | null {
if (depth < 0) return null
try {
const variantNames = this.buildPreferredDatNames(datName).filter(
(name) => allowThumbnail || !this.isThumbnailName(name)
)
const variants = new Set(
this.buildPreferredDatNames(datName).filter(
(name) => allowThumbnail || !this.isThumbnailName(name)
)
preferThumbnail
? [
...variantNames.filter((name) => this.isThumbnailName(name)),
...variantNames.filter((name) => !this.isThumbnailName(name))
]
: variantNames
)
const entries = readdirSync(dir)
const matchingFiles: string[] = []
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
matchingFiles.push(fullPath)
}
}
const preferredFile = this.getLargestExistingPath(
matchingFiles,
allowThumbnail,
preferThumbnail
)
if (preferredFile) {
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
return preferredFile
}
for (const entry of entries) {
const fullPath = join(dir, entry)
if (!statSync(fullPath).isDirectory()) continue
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
const found = this.recursiveFindDat(
fullPath,
datName,
depth - 1,
allowThumbnail,
preferThumbnail
)
if (found) return found
}
} catch {
@@ -252,13 +366,13 @@ export class ImageDecryptService {
*/
decryptImage(datPath: string): Buffer | null {
if (!existsSync(datPath)) {
console.log('[ImageDecrypt] file not found:', datPath)
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
return null
}
try {
const version = this.getDatVersion(datPath)
console.log(
imageDecryptLog(
'[ImageDecrypt] dat version:',
version,
'file:',
@@ -268,26 +382,24 @@ export class ImageDecryptService {
)
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 (version === 2) {
// WeChat 4.0 标准 dat 头: 07 08 56 32 08 07
imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
if (!this.aesKey) {
console.log('[ImageDecrypt] no AES key configured')
imageDecryptLog('[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)
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
return null
}
return decrypted
} catch (error) {
console.error('[ImageDecrypt] decrypt error:', error)
imageDecryptLog('[ImageDecrypt] decrypt error:', error)
return null
}
}
@@ -309,7 +421,7 @@ export class ImageDecryptService {
const unwrapped = this.unwrapWxgf(decrypted)
const ext = this.detectImageExtension(unwrapped)
if (!ext) {
console.log('[ImageDecrypt] unknown image format')
imageDecryptLog('[ImageDecrypt] unknown image format')
return null
}
@@ -346,12 +458,13 @@ export class ImageDecryptService {
const data = this.decryptImageToBase64(candidate)
if (data) return { data, filePath: candidate }
}
console.warn('[ImageDecrypt] all variants failed:', this.uniq(candidates))
imageDecryptLog('[ImageDecrypt] all variants failed:', this.uniq(candidates))
return null
}
/**
* 检测 DAT 文件版本
* 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
*/
private getDatVersion(inputPath: string): number {
const bytes = readFileSync(inputPath)
@@ -360,9 +473,6 @@ export class ImageDecryptService {
}
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
}
@@ -490,7 +600,11 @@ export class ImageDecryptService {
]
}
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
private getPreferredDatVariantPath(
inputPath: string,
allowThumbnail: boolean,
preferThumbnail = false
): string {
const actualDir = dirname(inputPath)
const base = this.normalizeDatBase(basename(inputPath))
const variants = this.buildPreferredDatNames(base)
@@ -499,13 +613,18 @@ export class ImageDecryptService {
: variants.filter((name) => !this.isThumbnailName(name))
const largest = this.getLargestExistingPath(
ordered.map((variant) => join(actualDir, variant)),
allowThumbnail
allowThumbnail,
preferThumbnail
)
if (largest) return largest
return inputPath
}
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null {
private getLargestExistingPath(
paths: string[],
allowThumbnail: boolean,
preferThumbnail = false
): string | null {
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
candidates
.filter((candidate) => existsSync(candidate))
@@ -518,6 +637,10 @@ export class ImageDecryptService {
})
.sort((left, right) => right.size - left.size)
const thumbnail = toSized(
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
)
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
const nonThumb = toSized(
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
)
+356 -50
View File
@@ -1,4 +1,4 @@
import './preload-env'
import './preload-env'
import {
app,
shell,
@@ -8,14 +8,16 @@ import {
clipboard,
Menu,
Tray,
dialog
dialog,
protocol
} from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { existsSync, promises as fsPromises } from 'fs'
import { extname } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { WechatDb } from './wechat-db'
import { bootstrapWcdbNative } from './wcdb4-client'
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
import { parseMessageContent } from './message-parser'
@@ -59,16 +61,28 @@ import {
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
import {
flushBootstrapCacheWritesSync,
getBootstrapCache,
getCachedMessagePage,
getCachedMessages,
mergeBootstrapAvatars,
mergeCachedContactAvatars,
saveBootstrapContacts,
saveBootstrapSelf,
saveCachedGroupSnapshot,
saveCachedMessages
} from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service'
import { appLogger } from './app-logger'
import type { AppLogEntry } from '../shared/app-log'
import { appUpdateService } from './services/app-update-service'
import { clearCache, getCacheSummary } from './services/cache-service'
import type { CacheClearScope } from './services/cache-service'
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
import { VideoAssetService } from './video-asset-service'
import { cancelExport, revealExport, runExport } from './export-service'
import type { ExportRequest } from '../shared/export'
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -78,19 +92,99 @@ installSafeConsole()
let voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
let videoAssetService: VideoAssetService | 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
let recallArchiveMonitor: RecallArchiveMonitor | null = null
let recallProtectionGeneration = 0
let recallJournalTimer: NodeJS.Timeout | null = null
let wcdbBootstrapPromise: Promise<unknown> | null = null
function configureRecallProtection(
wcdb4Client: Wcdb4Client,
accountRoot: string,
enabled: boolean,
installJournal = false
): void {
recallProtectionGeneration += 1
const generation = recallProtectionGeneration
recallArchiveMonitor?.stop()
recallArchiveMonitor = null
if (recallJournalTimer) {
clearTimeout(recallJournalTimer)
recallJournalTimer = null
}
configureRecallArchive(enabled ? accountRoot : '')
if (!enabled) return
const monitor = new RecallArchiveMonitor(
() =>
wcdb4Client.getSessions().map((session) => ({
md5: wcdb4Client.md5(session.username),
m_nsUsrName: session.username,
type: session.username.endsWith('@chatroom') ? 'group' : 'user',
activityKey: [
session.raw['last_timestamp'],
session.raw['sort_timestamp'],
session.raw['last_msg_locald_id'],
session.raw['last_msg_type'],
session.raw['summary']
].join(':')
})),
(sessionMd5) =>
chat.listMessages(sessionMd5, Math.floor(Date.now() / 1000) - 10 * 60, undefined, {
limit: 500
})
)
recallArchiveMonitor = monitor
// Session indexing is not required to open the UI. Defer it so large databases
// do not make db:init wait for every conversation to be enumerated.
setImmediate(() => {
if (generation === recallProtectionGeneration && recallArchiveMonitor === monitor) {
monitor.seedAll()
}
})
if (!installJournal) return
// Creating recall triggers scans every message table and runs synchronously in
// the main process. Only do this after the user explicitly enables protection;
// existing installations remain active without repeating the scan at startup.
recallJournalTimer = setTimeout(() => {
recallJournalTimer = null
if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return
const result = wcdb4Client.installRecallJournal(
wcdb4Client.getSessions().map((session) => session.username)
)
console.log(
`[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}`
)
}, 30_000)
}
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
protocol.registerSchemesAsPrivileged([
{
scheme: 'wxe-media',
privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true }
}
])
// WCDB's Windows runtime checks the host application name during wcdb_init.
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
app.setName(
process.platform === 'win32'
? 'WeFlow'
: process.env['WXE_USER_DATA']
? 'WechatExplorer Dev'
: 'WechatExplorer'
)
const isolatedUserData = process.env['WXE_USER_DATA']
if (isolatedUserData) app.setPath('userData', isolatedUserData)
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
null
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
@@ -105,8 +199,65 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
}
}
async function createLocalMediaResponse(request: Request, filePath: string): Promise<Response> {
const { size } = await fsPromises.stat(filePath)
const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg'
const commonHeaders = {
'Accept-Ranges': 'bytes',
'Content-Type': mimeType,
'Cache-Control': 'private, max-age=300'
}
const range = request.headers.get('range')
if (!range) {
const body =
request.method === 'HEAD' ? null : Uint8Array.from(await fsPromises.readFile(filePath))
return new Response(body, {
status: 200,
headers: { ...commonHeaders, 'Content-Length': String(size) }
})
}
const match = /^bytes=(\d+)-(\d*)$/i.exec(range.trim())
if (!match) {
return new Response(null, {
status: 416,
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
})
}
const start = Number(match[1])
const requestedEnd = match[2] ? Number(match[2]) : size - 1
const end = Math.min(requestedEnd, size - 1)
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) {
return new Response(null, {
status: 416,
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
})
}
const length = end - start + 1
let body: Buffer | null = null
if (request.method !== 'HEAD') {
const handle = await fsPromises.open(filePath, 'r')
try {
body = Buffer.allocUnsafe(length)
await handle.read(body, 0, length, start)
} finally {
await handle.close()
}
}
return new Response(body ? Uint8Array.from(body) : null, {
status: 206,
headers: {
...commonHeaders,
'Content-Length': String(length),
'Content-Range': `bytes ${start}-${end}/${size}`
}
})
}
function createWindow(): void {
// 鍒涘缓娴忚鍣ㄧ獥鍙?
// 创建浏览器窗口
const mainWindow = new BrowserWindow({
width: 1400,
height: 800,
@@ -128,8 +279,8 @@ function createWindow(): void {
return { action: 'deny' }
})
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
// 基于 electron-vite CLI 的渲染器热更新
// 加载开发环境的远程 URL,或生产环境的本地 HTML 文件
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
@@ -137,41 +288,88 @@ function createWindow(): void {
}
}
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
// 某些 API 只能在此事件发生后使用
app.whenReady().then(async () => {
protocol.handle('wxe-media', async (request) => {
const token = new URL(request.url).pathname.replace(/^\/+/, '')
const filePath = videoAssetService?.pathForToken(token)
if (!filePath) return new Response('Not found', { status: 404 })
try {
return await createLocalMediaResponse(request, filePath)
} catch (error) {
console.warn('[Video] local media request failed:', error)
return new Response('Media unavailable', { status: 500 })
}
})
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
appLogger.write({
level: 'info',
scope: 'lifecycle',
message: 'WechatExplorer 启动',
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
})
process.on('uncaughtException', (error) => {
appLogger.write({
level: 'error',
scope: 'main-process',
message: error.message,
details: { stack: error.stack }
})
})
process.on('unhandledRejection', (reason) => {
appLogger.write({
level: 'error',
scope: 'main-process',
message: reason instanceof Error ? reason.message : 'Promise 未处理拒绝',
details: {
stack: reason instanceof Error ? reason.stack : undefined,
reason: reason instanceof Error ? undefined : String(reason)
}
})
})
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
// 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)
}
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
createWindow()
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
console.log('[WCDB4] async bootstrap complete')
})
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
// 设置应用程序用户模型 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
// 开发环境中默认使用 F12 打开或关闭 DevTools
// 生产环境中忽略 CommandOrControl + R
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
ipcMain.on('ping', () => console.log('pong'))
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
ipcMain.handle('app-log:reveal', () => appLogger.reveal())
ipcMain.handle('app-update:getState', () => appUpdateService.getState())
ipcMain.handle('app-update:check', () => appUpdateService.check())
ipcMain.handle('app-update:download', () => appUpdateService.download())
ipcMain.handle('app-update:install', () => appUpdateService.install())
ipcMain.handle('cache:getSummary', () => getCacheSummary())
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'all']
if (!allowedScopes.includes(scope)) return getCacheSummary()
imageDecryptService = null
return clearCache(scope)
})
ipcMain.handle('db:init', async (_, key: string) => {
if (dbInitInFlight) return dbInitInFlight
dbInitInFlight = (async () => {
try {
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const settings = loadSettings()
@@ -186,17 +384,33 @@ app.whenReady().then(async () => {
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
saveSettings({ ...settings, dbRoot: resolvedRoot })
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
saveSettings({
...settings,
dbRoot: resolvedRoot,
imageKeyRoot: resolvedRoot
})
}
chat.setChatDb(nextWechatDb)
const wcdb4Client = nextWechatDb.getWcdb4Client()
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client)
videoAssetService = new VideoAssetService(wcdb4Client)
const monitoring = wcdb4Client.startMonitor((type, json) => {
wcdb4Client.invalidateSessionCache()
recallArchiveMonitor?.handleDatabaseChange(json)
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
}
})
setImmediate(() => {
const recentSession = wcdb4Client.getSessions()[0]
if (!recentSession?.username) return
void wcdb4Client
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
})
imageDecryptService = null
return { success: true, monitoring }
} catch (error) {
@@ -267,7 +481,10 @@ app.whenReady().then(async () => {
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
const settings = loadSettings()
const self = chat.getSelfAccountInfo()
const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot
// 优先级:chat 真实识别到的根 → self.accountRoot → settings.imageKeyRoot → settings.dbRoot
// 必须先看 chat.getCurrentAccountRoot(),否则 settings 缓存漂移会导致扫错目录。
const accountRoot =
chat.getCurrentAccountRoot() || self?.accountRoot || settings.imageKeyRoot || settings.dbRoot
const wxid = self?.wxid
const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
@@ -334,11 +551,26 @@ app.whenReady().then(async () => {
return getBootstrapCache(chat.getCurrentAccountRoot())
})
ipcMain.handle('db:getStartupCache', () => {
const settings = loadSettings()
return settings.dbRoot ? getBootstrapCache(settings.dbRoot) : null
})
ipcMain.handle(
'db:getCachedMessages',
(_, userMd5: string, startTime?: number, endTime?: number) => {
if (!chat.isReady()) return []
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
return accountRoot ? getCachedMessages(accountRoot, userMd5, startTime, endTime) : []
}
)
ipcMain.handle(
'db:getCachedMessagePage',
(_, userMd5: string, startTime?: number, endTime?: number) => {
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
return accountRoot
? getCachedMessagePage(accountRoot, userMd5, startTime, endTime)
: { hit: false, messages: [] }
}
)
@@ -361,8 +593,14 @@ app.whenReady().then(async () => {
ipcMain.handle(
'db:getMessages',
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
const messages = chat.listMessages(userMd5, startTime, endTime, options)
async (
_,
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
) => {
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
if (chat.isReady()) {
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
}
@@ -370,7 +608,13 @@ app.whenReady().then(async () => {
}
)
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
if (snapshot && chat.isReady()) {
saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
}
return snapshot
})
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
@@ -413,6 +657,23 @@ app.whenReady().then(async () => {
return exportGroupReport(request)
})
ipcMain.handle('export:start', async (event, request: ExportRequest) => {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) return { success: false, error: '窗口不可用' }
return runExport(request, window)
})
ipcMain.handle('export:cancel', (_, jobId: string) => {
cancelExport(jobId)
return { success: true }
})
ipcMain.handle('export:reveal', async (_, path: string) => {
try {
await revealExport(path)
return { success: true }
} catch (error) {
return { success: false, error: String(error) }
}
})
ipcMain.handle('report:listGenerated', async () => {
return listGeneratedReports()
})
@@ -455,7 +716,7 @@ app.whenReady().then(async () => {
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
_sessionId?: string,
options?: { force?: boolean }
options?: { force?: boolean; preferThumbnail?: boolean }
) => {
void _sessionId
if (!imageDecryptService) {
@@ -472,12 +733,28 @@ app.whenReady().then(async () => {
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const force = options?.force === true
const preferThumbnail = options?.preferThumbnail === true
const imageCacheKey = [
imageMd5 || '',
imageDatName || '',
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
].join('|')
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey)
if (cachedImage) {
return {
success: true,
data: cachedImage.data,
isThumb: cachedImage.isThumbnail,
filePath: cachedImage.filePath
}
}
let filePath = force
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
: null
if (!filePath) {
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
allowThumbnail: true
allowThumbnail: true,
preferThumbnail
})
}
if (!filePath) {
@@ -489,12 +766,18 @@ app.whenReady().then(async () => {
return { success: false, error: '图片解密失败' }
}
return {
const result = {
success: true,
data: decrypted.data,
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
filePath: decrypted.filePath
}
imageDecryptService.cacheDecodedImage(imageCacheKey, {
data: result.data,
filePath: result.filePath,
isThumbnail: result.isThumb
})
return result
}
)
@@ -556,9 +839,9 @@ app.whenReady().then(async () => {
if (!provider || !model) {
return { success: false, error: '当前 AI 模型不存在' }
}
if (!model.capabilities.vision) {
return { success: false, error: '当前模型不支持图片理解' }
}
// Capability metadata is stored per machine. A model verified on macOS
// may still be unmarked on Windows, so do not reject before making the
// real multimodal request. The provider response remains authoritative.
// request 来自 renderer,imageHash 是 md5(优先)或 sha256(...),dataUrl 在内部算出
// 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
return imageInsightService.analyze(request)
@@ -593,6 +876,15 @@ app.whenReady().then(async () => {
return stickerService.resolveSticker(cdnUrl, md5)
})
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
if (!videoAssetService) {
const client = chat.getChatDb()?.getWcdb4Client()
if (!client) return { success: false, error: '数据库尚未连接' }
videoAssetService = new VideoAssetService(client)
}
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [])
})
// -------- Settings & API service --------
ipcMain.handle('settings:get', () => ({
@@ -607,13 +899,22 @@ app.whenReady().then(async () => {
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
const nextSettings = saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
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: '' })
}
if ('recallProtectionEnabled' in patch && chat.isReady()) {
const currentDb = chat.getChatDb()
if (currentDb) {
configureRecallProtection(
currentDb.getWcdb4Client(),
chat.getCurrentAccountRoot(),
nextSettings.recallProtectionEnabled,
nextSettings.recallProtectionEnabled && !before.recallProtectionEnabled
)
}
}
return {
settings: imageKeyConfigService.getLegacySettingsView(),
@@ -635,6 +936,11 @@ app.whenReady().then(async () => {
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
const ok = chat.reopenWithRoot(accountRoot)
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
// 同步 imageKeyRoot,避免自动获取扫描到旧目录
const settings = loadSettings()
if (accountRoot && accountRoot !== settings.imageKeyRoot) {
saveSettings({ ...settings, imageKeyRoot: accountRoot })
}
const info = chat.getSelfAccountInfo()
return { success: true, info }
})
@@ -655,9 +961,10 @@ app.whenReady().then(async () => {
return error ? { success: false, error } : { success: true }
})
ipcMain.handle('db:disconnect', () => {
if (!chat.isReady()) return { success: false, error: '数据库当前未连接' }
chat.setChatDb(null)
ipcMain.handle('db:disconnect', (_, options?: { closeNative?: boolean }) => {
// 断开操作保持幂等:渲染进程可能已标记断开,或主进程连接已先行失效。
// 即使当前未就绪,也应让用户正常返回登录页。
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
return { success: true }
})
@@ -719,9 +1026,7 @@ app.whenReady().then(async () => {
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
})
createWindow()
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
const settings = loadSettings()
if (settings.apiEnabled) {
await apiServer.start(settings.apiHost, settings.apiPort)
@@ -735,15 +1040,15 @@ app.whenReady().then(async () => {
}
app.on('activate', function () {
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
// macOS 上点击 Dock 图标且没有其他窗口打开时,
// 通常会在应用程序中重新创建一个窗口。
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
// 除 macOS 外,所有窗口关闭时退出应用。在 macOS 上,
// 应用程序及其菜单栏通常会保持活动状态,直到用户
// 明确使用 Cmd + Q 退出。
app.on('window-all-closed', () => {
if (TRAY_MODE) return
if (process.platform !== 'darwin') {
@@ -753,6 +1058,7 @@ app.on('window-all-closed', () => {
app.on('before-quit', async () => {
agentHubService.stop()
flushBootstrapCacheWritesSync()
chat.setChatDb(null)
await apiServer.stop().catch(() => undefined)
if (tray) {
+53 -4
View File
@@ -889,7 +889,8 @@ export class KeyService {
const dirName = normalized.split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) pushUnique(dirName)
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
// 仅支持 WeChat 4.0:路径识别只匹配 xwechat_files
const marker = normalized.match(/[\\/]xwechat_files/i)
if (marker) {
const root = normalized.slice(0, marker.index! + marker[0].length)
try {
@@ -934,15 +935,49 @@ export class KeyService {
onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(userDir, 32)
let { ciphertext, xorKey } = result
const firstDiag = (this as { _imageTemplateDiag?: {
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
} })._imageTemplateDiag
// 如果找不到密钥,尝试扫描更多文件
if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(userDir, 100)
xorKey = result.xorKey
}
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
if (!ciphertext) {
// 用诊断信息给具体提示
const diag = (this as { _imageTemplateDiag?: {
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
} })._imageTemplateDiag || firstDiag
if (!diag || diag.totalTFiles === 0) {
return {
success: false,
error:
'在账号目录下未找到任何 _t.dat 图片文件。\n' +
`扫描路径:${diag?.userDir || userDir || '(空)'}\n` +
'原因:微信没在本地生成缩略图。\n' +
'请让用户在微信里打开任意聊天的图片大图(等"原图"按钮可点击),然后再试。'
}
}
if (diag.v2Count === 0 && diag.nonV2Count > 0) {
return {
success: false,
error:
`找到 ${diag.totalTFiles} 个 _t.dat,但都不是 V2 头(可能图片尚未解密到本地,或微信版本不同)。\n` +
`扫描路径:${diag.userDir}\n` +
'请让用户在微信里打开 2-3 张不同的图片大图,等"原图"按钮可点击后再试。'
}
}
return {
success: false,
error:
`找到 ${diag.totalTFiles} 个 _t.dat,其中 ${diag.v2Count} 个是 V2 头,但没有长度 ≥ 0x1F 的有效模板。\n` +
`扫描路径:${diag.userDir}\n` +
'请在微信中查看更多图片后再试。'
}
}
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
@@ -1005,6 +1040,8 @@ export class KeyService {
let ciphertext: Buffer | null = null
const tailCounts: Record<string, number> = {}
let v2Count = 0
let nonV2Count = 0
for (const f of files.slice(0, 32)) {
try {
@@ -1013,8 +1050,11 @@ export class KeyService {
// 统计末尾两字节用于 XOR 密钥
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
v2Count++
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
tailCounts[key] = (tailCounts[key] ?? 0) + 1
} else {
nonV2Count++
}
// 提取密文(取第一个有效的)
@@ -1031,6 +1071,15 @@ export class KeyService {
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
}
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
const diag = {
userDir,
totalTFiles: files.length,
v2Count,
nonV2Count
}
;(this as { _imageTemplateDiag?: unknown })._imageTemplateDiag = diag
return { ciphertext, xorKey }
}
+234 -6
View File
@@ -16,6 +16,22 @@ type ShareContent = {
appname?: string
typeVal?: string
}
type MiniProgramContent = {
type: 'miniProgram'
title: string
description?: string
appName?: string
iconUrl?: string
thumbMd5?: string
thumbDatName?: string
thumbDataUrl?: string
}
type RedPacketContent = {
type: 'redPacket'
title: string
description?: string
url?: string
}
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
type ImageContent = {
type: 'image'
@@ -24,6 +40,15 @@ type ImageContent = {
aeskey?: string
encrypVer?: number
}
type VideoContent = {
type: 'video'
md5?: string
newMd5?: string
rawMd5?: string
duration?: number
width?: number
height?: number
}
type StickerContent = {
type: 'sticker'
md5?: string
@@ -40,8 +65,23 @@ type QuoteContent = {
quotedContent?: string
quotedSender?: string
quotedType?: string
quotedImageMd5?: string
quotedImageDatName?: string
}
type SystemContent = {
type: 'system'
content: string
raw?: string
pat?: boolean
recall?: {
targetId?: string
targetIds?: string[]
replacement: string
actor?: string
sessionId?: string
recallTime?: number
}
}
type SystemContent = { type: 'system'; content: string; raw?: string }
type UnknownContent = { type: 'unknown'; raw: string }
export type ParsedContent =
@@ -50,8 +90,11 @@ export type ParsedContent =
| LocationContent
| CardContent
| ShareContent
| MiniProgramContent
| RedPacketContent
| VoipContent
| ImageContent
| VideoContent
| StickerContent
| QuoteContent
| SystemContent
@@ -69,6 +112,8 @@ export function parseMessageContent(content: string, messageType: number): Parse
return parseImageMessage(normalized)
case 42:
return parseCardMessage(normalized)
case 43:
return parseVideoMessage(normalized)
case 47:
return parseStickerMessage(normalized)
case 48:
@@ -85,9 +130,31 @@ export function parseMessageContent(content: string, messageType: number): Parse
}
}
function parseVideoMessage(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content }
const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined
return { type: 'video', md5, newMd5, rawMd5, duration, width, height }
}
function parseSystemMessage(content: string): ParsedContent {
const stripped = stripChatroomPrefix(content)
const decoded = decodeXmlEntities(stripped)
const recall = extractRecallMessage(decoded)
if (recall) {
return {
type: 'system',
content: recall.replacement,
raw: content,
recall
}
}
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
if (delChatroomMemberText) {
return {
@@ -113,6 +180,50 @@ function parseSystemMessage(content: string): ParsedContent {
}
}
function extractRecallMessage(xml: string):
| {
targetId?: string
targetIds?: string[]
replacement: string
actor?: string
sessionId?: string
recallTime?: number
}
| undefined {
if (!/<revokemsg\b/i.test(xml)) return undefined
const replacement = normalizeSystemText(
extractXmlValue(xml, 'replacemsg') ||
extractXmlNodeText(xml, 'replacemsg') ||
extractXmlValue(xml, 'content') ||
extractXmlNodeText(xml, 'content')
)
if (!replacement) return undefined
const actorMatch =
/^["“](.+?)["”]\s*撤回了一条消息/.exec(replacement) ||
/^(.+?)\s*撤回了一条消息/.exec(replacement)
const targetIds = Array.from(
new Set(
[
extractXmlValue(xml, 'newmsgid'),
extractXmlValue(xml, 'msgid'),
extractXmlValue(xml, 'clientmsgid')
].filter(Boolean)
)
)
return {
targetId: targetIds[0] || undefined,
targetIds,
replacement,
actor: actorMatch?.[1]?.trim() || undefined,
sessionId: extractXmlValue(xml, 'session') || undefined,
recallTime: Number(extractXmlValue(xml, 'revoketime')) || undefined
}
}
function parseImageMessage(content: string): ParsedContent {
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
@@ -301,6 +412,10 @@ function parseLocationMessage(content: string): ParsedContent {
function parseShareMessage(content: string): ParsedContent {
const appMsgType = extractAppMsgType(content)
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
const sticker = parseStickerMessage(content)
if (sticker.type === 'sticker') return sticker
}
if (appMsgType === '57' || content.includes('<refermsg>')) {
const quote = parseQuoteMessage(content)
const title = extractXmlValue(content, 'title') || undefined
@@ -310,7 +425,34 @@ function parseShareMessage(content: string): ParsedContent {
content: title,
quotedContent: quote.content || '[引用消息]',
quotedSender: quote.sender,
quotedType: quote.type
quotedType: quote.type,
quotedImageMd5: quote.imageMd5,
quotedImageDatName: quote.imageDatName
}
}
if (appMsgType === '33' || appMsgType === '36') {
return {
type: 'miniProgram',
title: extractXmlValue(content, 'title') || '小程序',
description: extractXmlValue(content, 'des') || undefined,
appName:
extractXmlValue(content, 'sourcedisplayname') ||
extractXmlValue(content, 'appname') ||
'小程序',
iconUrl: decodeXmlUrl(extractXmlValue(content, 'weappiconurl')) || undefined,
thumbMd5: normalizeMd5(
extractXmlValue(content, 'cdnthumbmd5') || extractXmlValue(content, 'md5')
)
}
}
if (appMsgType === '2001') {
return {
type: 'redPacket',
title: extractXmlValue(content, 'title') || '微信红包',
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
}
}
@@ -327,7 +469,13 @@ function parseShareMessage(content: string): ParsedContent {
return { type: 'share', title, des, url, appname, typeVal }
}
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
function parseQuoteMessage(content: string): {
content?: string
sender?: string
type?: string
imageMd5?: string
imageDatName?: string
} {
const referMsgStart = content.indexOf('<refermsg>')
const referMsgEnd = content.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) return {}
@@ -343,8 +491,16 @@ function parseQuoteMessage(content: string): { content?: string; sender?: string
switch (referType) {
case '1':
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
case '3':
return { sender, content: '[图片]', type: referType }
case '3': {
const image = parseImageMessage(referContent)
return {
sender,
content: '[图片]',
type: referType,
imageMd5: image.type === 'image' ? image.md5 : undefined,
imageDatName: image.type === 'image' ? image.datName : undefined
}
}
case '34':
return { sender, content: '[语音]', type: referType }
case '43':
@@ -372,6 +528,10 @@ function extractAppMsgType(content: string): string {
const inner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
.replace(/<weappinfo[\s\S]*?<\/weappinfo>/gi, '')
.replace(/<appattach[\s\S]*?<\/appattach>/gi, '')
.replace(/<wcpayinfo[\s\S]*?<\/wcpayinfo>/gi, '')
.replace(/<findernamecard[\s\S]*?<\/findernamecard>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
return typeMatch?.[1]?.trim() || ''
}
@@ -433,7 +593,10 @@ function extractXmlValue(xml: string, tagName: string): string {
}
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
const pattern = new RegExp(
`<${tagName}\\b[^>]*?(?:\\s|^)${attrName}\\s*=\\s*["']([^"']*)["']`,
'i'
)
const match = xml.match(pattern)
return match ? match[1].trim() : ''
}
@@ -550,6 +713,71 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
return hexMatch?.[1]?.toLowerCase()
}
export function parseImageBufferDataUrlFromRow(
row: Record<string, unknown>
): string | undefined {
const raw = pickRowString(row, [
'ImgBuf',
'imgBuf',
'img_buf',
'imageBuffer',
'image_buffer',
'thumbBuffer',
'thumb_buffer',
'WCDB_CT_img_buf',
'WCDB_CT_ImgBuf'
])
const buffer = decodeInlineImageBuffer(raw)
if (!buffer || buffer.length === 0) return undefined
const mime = detectImageMime(buffer)
return mime ? `data:${mime};base64,${buffer.toString('base64')}` : undefined
}
function decodeInlineImageBuffer(raw: unknown): Buffer | null {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
if (raw instanceof Uint8Array) return Buffer.from(raw)
if (Array.isArray(raw)) return Buffer.from(raw)
if (typeof raw === 'object') {
const record = raw as { buffer?: unknown; data?: unknown }
return decodeInlineImageBuffer(record.buffer ?? record.data)
}
if (typeof raw !== 'string') return null
const value = raw.trim()
const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,(.+)$/i.exec(value)
const encoded = dataUrl?.[1] || value
if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded)) return null
try {
return Buffer.from(encoded, 'base64')
} catch {
return null
}
}
function detectImageMime(buffer: Buffer): string | undefined {
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return 'image/jpeg'
}
if (
buffer.length >= 8 &&
buffer[0] === 0x89 &&
buffer.subarray(1, 4).toString('ascii') === 'PNG'
) {
return 'image/png'
}
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('ascii'))) {
return 'image/gif'
}
if (
buffer.length >= 12 &&
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'image/webp'
}
return undefined
}
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
+12
View File
@@ -0,0 +1,12 @@
import { app } from 'electron'
import { existsSync } from 'fs'
import { join } from 'path'
export function isPackagedRuntime(): boolean {
if (app.isPackaged) return true
return (
existsSync(join(process.resourcesPath, 'app.asar')) &&
existsSync(join(process.resourcesPath, 'app-update.yml'))
)
}
@@ -5,6 +5,7 @@ import { AIProviderService } from './ai-provider-service'
import {
buildGroupReportInput,
getSummaryDateRange,
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
GROUP_REPORT_SYSTEM_PROMPT,
isInternalName,
parseGroupDailyReport,
@@ -68,14 +69,39 @@ export async function generateAgentGroupReport(
{ role: 'user', content: input.prompt }
])
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
const report = parseGroupDailyReport(
ai.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard,
input.metadata,
input.media
)
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
parseGroupDailyReport(
raw,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard,
input.metadata,
input.media
)
let report: ReturnType<typeof parseGroupDailyReport>
try {
report = parseReport(ai.data)
} catch (parseError) {
const repaired = await aiProvider.chat([
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
{ role: 'user', content: ai.data }
])
if (!repaired.success || !repaired.data) {
const cause = parseError instanceof Error ? parseError.message : String(parseError)
return {
success: false,
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause}`
}
}
try {
report = parseReport(repaired.data)
} catch (repairError) {
return {
success: false,
error: repairError instanceof Error ? repairError.message : String(repairError)
}
}
}
const exported = await exportGroupReport({ report, metadata: input.metadata })
if (!exported.success || !exported.pngPath) {
return { success: false, error: exported.error || '总结图片生成失败' }
+424 -7
View File
@@ -14,7 +14,16 @@ import type {
} from '../../shared/agent-hub'
import type { AppSettings } from './settings-store'
import { generateAgentGroupReport } from './agent-group-report-service'
import { isReady, listMessages, listRecentChat, resolveMd5 } from './chat-service'
import { AIProviderService } from './ai-provider-service'
import { isPackagedRuntime } from '../runtime-mode'
import {
getGroupSnapshot,
isReady,
listContacts,
listMessages,
listRecentChat,
resolveMd5
} from './chat-service'
const execFileAsync = promisify(execFile)
const HEALTH_INTERVAL_MS = 5_000
@@ -39,12 +48,28 @@ interface GroupReportIntent {
interface ContactChatIntent {
contact: string
limit: number
summarize: boolean
}
interface GroupMemberChatIntent {
group: string
member: string
range: 'today' | 'yesterday' | '7days'
days: number
goal: string
}
interface NaturalLanguageResult {
command?: string
reply?: string
}
const agentAIProvider = new AIProviderService()
function resolveBundledBinary(
resourceSegments: string[],
executable: string,
packaged = app.isPackaged,
packaged = isPackagedRuntime(),
platform = process.platform,
arch = process.arch
): string {
@@ -56,7 +81,7 @@ function resolveBundledBinary(
}
export function resolveWechatConnectorBinaryPath(
packaged = app.isPackaged,
packaged = isPackagedRuntime(),
platform = process.platform,
arch = process.arch
): string {
@@ -314,6 +339,7 @@ class AgentHubService {
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
const reportIntent = this.matchGroupReportIntent(text)
if (reportIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog(
@@ -328,20 +354,39 @@ class AgentHubService {
return this.sendHubJson(response, 202, { status: 'generating' })
}
const groupMemberIntent = this.matchGroupMemberChatIntent(text)
if (groupMemberIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
void this.summarizeGroupMemberChat(inbound, groupMemberIntent)
return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' })
}
const contactChatIntent = this.matchContactChatIntent(text)
if (contactChatIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
if (contactChatIntent.summarize) {
void this.summarizeContactChat(inbound, contactChatIntent)
return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' })
}
await this.replyContactChat(inbound, contactChatIntent)
return this.sendHubJson(response, 200, { status: 'ok' })
}
const limit = this.matchRecentChatIntent(text)
if (limit === null) {
const recentChatLimit = this.matchRecentChatIntent(text)
if (recentChatLimit === null && text.trim()) {
if (messageId) this.processedMessages.set(messageId, Date.now())
void this.handleNaturalLanguage(inbound, text)
return this.sendHubJson(response, 202, {
status: 'processing',
mode: 'natural-language'
})
}
if (recentChatLimit === null) {
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
}
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
const items = listRecentChat(limit)
const items = listRecentChat(recentChatLimit)
const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}${item.type === 'group' ? '群聊' : '联系人'}`
@@ -360,6 +405,140 @@ class AgentHubService {
this.sendHubJson(response, 200, { status: 'ok' })
}
private async handleNaturalLanguage(inbound: InboundMessage, text: string): Promise<void> {
try {
const result = await this.resolveNaturalLanguage(text)
if (result.reply) {
await this.sendConnector(inbound, this.formatAIReply(result.reply))
this.addLog('agent-hub', 'info', '自然语言回复已发送')
return
}
if (!result.command) {
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
return
}
this.addLog('agent-hub', 'info', `自然语言已理解为:${result.command}`)
const reportIntent = this.matchGroupReportIntent(result.command)
if (reportIntent) {
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch(() => undefined)
await this.generateAndSendReport(inbound, reportIntent)
return
}
const groupMemberIntent = this.matchGroupMemberChatIntent(result.command)
if (groupMemberIntent) {
await this.summarizeGroupMemberChat(inbound, groupMemberIntent)
return
}
const contactIntent = this.matchContactChatIntent(result.command)
if (contactIntent) {
if (contactIntent.summarize) await this.summarizeContactChat(inbound, contactIntent)
else await this.replyContactChat(inbound, contactIntent)
return
}
const recentLimit = this.matchRecentChatIntent(result.command)
if (recentLimit !== null) {
await this.replyRecentChats(inbound, recentLimit)
return
}
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
} catch (error) {
this.addLog('agent-hub', 'error', `自然语言处理失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `处理失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
const items = listRecentChat(limit)
const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}${item.type === 'group' ? '群聊' : '联系人'}`
})
await this.sendConnector(
inbound,
lines.length ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` : '暂时没有找到最近会话。'
)
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
}
private async resolveNaturalLanguage(text: string): Promise<NaturalLanguageResult> {
const result = await agentAIProvider.chat([
{
role: 'system',
content: `你是 WechatExplorer 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
支持的工具:
1. recent:查看最近会话,参数 limit 为 1-20。
2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。
3. report:生成某个群的群聊总结图片,参数 group 和 rangetoday、yesterday、7days)。
4. group_member:分析某个群里某位成员的发言,参数 group、member、rangetoday、yesterday、7days)、days1-30)和 goal(保留用户希望总结、研究人物、提取观点等完整目标)。只要用户同时提到群聊和群成员,应优先使用 group_member,不能识别成 contact。
5. chat:不需要工具的普通对话,reply 用简洁中文直接回答。
输出格式:{"type":"recent|contact|report|group_member|chat","limit":5,"contact":"","group":"","member":"","range":"today","days":3,"goal":"","reply":""}
不要声称已经读取未调用的聊天记录,不要执行电脑控制、文件操作、付款或发送给其他联系人。`
},
{ role: 'user', content: text.slice(0, 1000) }
])
if (!result.success || !result.data) {
this.addLog('agent-hub', 'warn', `自然语言理解不可用:${result.error || 'AI 未返回内容'}`)
return {}
}
try {
const json = result.data.match(/\{[\s\S]*\}/)?.[0]
if (!json) return {}
const parsed = JSON.parse(json) as Record<string, unknown>
const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5))
if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` }
if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) {
return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` }
}
if (parsed['type'] === 'report' && String(parsed['group'] || '').trim()) {
const range =
parsed['range'] === '7days'
? '最近7天'
: parsed['range'] === 'yesterday'
? '昨天'
: '今天'
return { command: `生成${String(parsed['group']).trim()}${range}的群聊总结图片` }
}
if (
parsed['type'] === 'group_member' &&
String(parsed['group'] || '').trim() &&
String(parsed['member'] || '').trim()
) {
const range =
parsed['range'] === 'yesterday'
? '昨天'
: parsed['range'] === 'today'
? '今天'
: '最近7天'
const group = String(parsed['group'] || '')
.trim()
.replace(/(?:群聊|群)+$/g, '')
const days = Math.max(1, Math.min(30, Number(parsed['days']) || 7))
const goal = String(parsed['goal'] || '总结发言').trim()
return {
command: `看看${group}群里${String(parsed['member']).trim()}${range === '最近7天' ? `最近${days}` : range}说了什么,${goal}`
}
}
if (parsed['type'] === 'chat') {
const reply = String(parsed['reply'] || '').trim()
return reply ? { reply: reply.slice(0, 1500) } : {}
}
} catch (error) {
this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`)
}
return {}
}
private async replyContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
@@ -395,6 +574,180 @@ class AgentHubService {
this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`)
}
private async summarizeContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
): Promise<void> {
try {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
const contact = resolveMd5(intent.contact)
if (!contact || contact.type !== 'user') {
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
return
}
await this.sendConnector(
inbound,
`收到!正在整理和${contact.m_nsNickName}的近期聊天,请等待…`
)
const endTime = Math.floor(Date.now() / 1000)
const startTime = endTime - 7 * 24 * 60 * 60
const messages = listMessages(contact.md5, startTime, endTime, { limit: 300 }).slice(-300)
if (!messages.length) {
await this.sendConnector(inbound, `最近 7 天没有找到和${contact.m_nsNickName}的聊天记录。`)
return
}
const transcript = messages
.map((message) => {
const speaker = message.isSender ? '我' : contact.m_nsNickName
return `[${message.datetime}] ${speaker}${this.describeChatMessage(message.content, message.type)}`
})
.join('\n')
const summary = await agentAIProvider.chat([
{
role: 'system',
content:
'你是私人聊天记录总结助手。仅根据提供的记录总结,不编造。按日期或主题整理关键进展、双方观点、决定、待办和未解决问题;忽略无意义表情,保留重要数字与事实。使用适合微信阅读的简洁中文。'
},
{
role: 'user',
content: `请总结我和“${contact.m_nsNickName}”最近 7 天聊了什么。\n\n聊天记录:\n${transcript}`
}
])
if (!summary.success || !summary.data?.trim()) {
throw new Error(summary.error || 'AI 未返回总结')
}
await this.sendConnector(
inbound,
this.formatAIReply(
`${contact.m_nsNickName}最近聊天总结(近 7 天,共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
)
)
this.addLog('agent-hub', 'info', `联系人聊天总结已发送(${messages.length} 条)`)
} catch (error) {
this.addLog('agent-hub', 'error', `联系人聊天总结失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `聊天总结失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private async summarizeGroupMemberChat(
inbound: InboundMessage,
intent: GroupMemberChatIntent
): Promise<void> {
try {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
const group = this.resolveGroup(intent.group)
if (!group) {
await this.sendConnector(inbound, `没有找到群聊“${intent.group}”。`)
return
}
const snapshot = getGroupSnapshot(group.md5)
const memberQuery = intent.member.trim().toLowerCase()
const member = snapshot?.members.find((item) =>
[item.groupNickname, item.wechatNickname, item.remark, item.nickname, item.wxid].some(
(name) =>
String(name || '')
.trim()
.toLowerCase() === memberQuery
)
)
if (!member) {
await this.sendConnector(
inbound,
`没有在“${group.m_nsNickName}”找到成员“${intent.member}”。`
)
return
}
const displayName =
member.groupNickname || member.wechatNickname || member.remark || member.nickname
await this.sendConnector(
inbound,
`收到!正在整理${displayName}在“${group.m_nsNickName}”的近期发言,请等待…`
)
const now = new Date()
const todayStart = Math.floor(
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
)
const startTime =
intent.range === 'today'
? todayStart
: intent.range === 'yesterday'
? todayStart - 24 * 60 * 60
: Math.floor(Date.now() / 1000) - intent.days * 24 * 60 * 60
const endTime = intent.range === 'yesterday' ? todayStart - 1 : Math.floor(Date.now() / 1000)
const aliases = new Set(
[member.groupNickname, member.wechatNickname, member.remark, member.nickname]
.map((name) =>
String(name || '')
.trim()
.toLowerCase()
)
.filter(Boolean)
)
const messages = listMessages(group.md5, startTime, endTime, { limit: 10_000 })
.filter(
(message) =>
String(message.senderId || '').trim() === member.wxid ||
aliases.has(
String(message.name || '')
.trim()
.toLowerCase()
)
)
.slice(-1000)
if (!messages.length) {
await this.sendConnector(
inbound,
`所选时间范围没有找到${displayName}在“${group.m_nsNickName}”的发言。`
)
return
}
const transcript = messages
.map(
(message) =>
`[${message.datetime}] ${this.describeChatMessage(message.content, message.type)}`
)
.join('\n')
const summary = await agentAIProvider.chat([
{
role: 'system',
content:
'你是擅长分析微信群聊的助手。严格依据提供的发言完成用户的原始要求,输出结构和侧重点由内容决定,不套固定模板。可以归纳人物特征、兴趣、表达习惯和群内角色,但必须区分事实与推测,为推测说明依据和不确定性,不得编造。使用适合微信阅读的中文。'
},
{
role: 'user',
content: `用户原始要求:${intent.goal}\n分析对象:“${displayName}”在群聊“${group.m_nsNickName}”中的发言。\n时间范围:${intent.range === 'today' ? '今天' : intent.range === 'yesterday' ? '昨天' : `最近 ${intent.days}`}\n共提供 ${messages.length} 条发言。\n\n发言记录:\n${transcript}`
}
])
if (!summary.success || !summary.data?.trim()) {
throw new Error(summary.error || 'AI 未返回总结')
}
await this.sendConnector(
inbound,
this.formatAIReply(
`${displayName}在“${group.m_nsNickName}”的发言总结(共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
)
)
this.addLog('agent-hub', 'info', `群成员发言总结已发送(${messages.length} 条)`)
} catch (error) {
this.addLog('agent-hub', 'error', `群成员发言总结失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `群成员发言总结失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private describeChatMessage(content: string, type: string): string {
const normalized = String(content || '')
.replace(/\s+/g, ' ')
@@ -404,6 +757,15 @@ class AgentHubService {
return `[${label}]`
}
private formatAIReply(content: string): string {
return content
.replace(/\r\n?/g, '\n')
.replace(/[ \t]*•[ \t]*/g, '\n• ')
.replace(/[ \t]+(?=\d+[.、][ \t])/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
private async generateAndSendReport(
inbound: InboundMessage,
intent: GroupReportIntent
@@ -465,7 +827,62 @@ class AgentHubService {
if (!contact) return null
const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10)
return { contact, limit: Math.max(1, Math.min(20, limit)) }
const summarize = /(聊了什么|聊什么|说了什么|谈了什么|总结)/.test(normalized)
return { contact, limit: Math.max(1, Math.min(20, limit)), summarize }
}
private matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null {
const normalized = text.trim().replace(/[,。!??:]/g, '')
const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)'
const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)'
const patterns = [
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,]+(.+?)${timePattern}${actionPattern}`
),
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}`
)
]
for (const pattern of patterns) {
const match = normalized.match(pattern)
if (match?.[1]?.trim() && match[2]?.trim()) {
const range = /昨天|昨日/.test(match[3] || '')
? 'yesterday'
: /今天|今日/.test(match[3] || '')
? 'today'
: '7days'
const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7))
return {
group: match[1].trim(),
member: match[2].trim(),
range,
days,
goal: normalized
}
}
}
return null
}
private resolveGroup(query: string): ReturnType<typeof resolveMd5> {
const normalize = (value: string): string =>
value
.trim()
.toLowerCase()
.replace(/[\s,。!??:、“”'‘’]/g, '')
.replace(/(?:群聊|群)+$/g, '')
const target = normalize(query)
if (!target) return null
const groups = listContacts().filter((contact) => contact.type === 'group')
return (
groups.find((contact) => normalize(contact.m_nsNickName) === target) ||
groups.find((contact) => {
const name = normalize(contact.m_nsNickName)
return name.includes(target) || target.includes(name)
}) ||
null
)
}
private matchGroupReportIntent(text: string): GroupReportIntent | null {
+3 -13
View File
@@ -41,7 +41,6 @@ export class AIProviderService {
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
list(): AIProviderListResult {
this.ensureEnvironmentMigration()
try {
const data = this.readMetadata()
return {
@@ -195,6 +194,9 @@ export class AIProviderService {
const imageError = validateVisionImage(imagePart.dataUrl)
if (imageError) throw new Error(imageError)
const result = await this.request(messages as AIMessage[], options)
if (options?.providerId && options.modelId) {
this.markCapabilities(options.providerId, options.modelId, { vision: true, ocr: true })
}
return { success: true, ...result }
} catch (error) {
return { success: false, error: safeAIError(error) }
@@ -335,18 +337,6 @@ export class AIProviderService {
if (changed) this.writeMetadata(data)
}
private ensureEnvironmentMigration(): void {
const data = this.readMetadata()
if (data.providers.length) return
const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim()
if (!apiKey) return
this.migrateLegacy({
apiKey,
baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''),
model: String(import.meta.env.VITE_AI_MODEL || '')
})
}
private toSummary(
provider: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>,
defaultProviderId?: string
+120
View File
@@ -0,0 +1,120 @@
import { app, BrowserWindow } from 'electron'
import { autoUpdater, type ProgressInfo } from 'electron-updater'
import type { AppUpdateCheckResult, AppUpdateState } from '../../shared/app-update'
import { isPackagedRuntime } from '../runtime-mode'
export class AppUpdateService {
private state: AppUpdateState = {
status: 'idle',
currentVersion: app.getVersion()
}
constructor() {
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('checking-for-update', () => this.setState({ status: 'checking' }))
autoUpdater.on('update-available', (info) =>
this.setState({ status: 'available', version: info.version, message: '发现新版本' })
)
autoUpdater.on('update-not-available', () =>
this.setState({ status: 'not-available', message: '当前已是最新版本' })
)
autoUpdater.on('download-progress', (progress: ProgressInfo) =>
this.setState({
status: 'downloading',
percent: progress.percent,
transferred: progress.transferred,
total: progress.total,
bytesPerSecond: progress.bytesPerSecond
})
)
autoUpdater.on('update-downloaded', (info) =>
this.setState({
status: 'downloaded',
version: info.version,
percent: 100,
message: '更新已下载'
})
)
autoUpdater.on('error', (error) =>
this.setState({ status: 'error', message: error.message || '更新失败' })
)
}
getState(): AppUpdateState {
return { ...this.state, currentVersion: app.getVersion() }
}
async check(): Promise<AppUpdateCheckResult> {
if (!isPackagedRuntime()) {
const state = this.setState({
status: 'unsupported',
message: '开发模式不执行安装包更新,请在正式安装包中检查更新'
})
return { success: false, state }
}
try {
const result = await autoUpdater.checkForUpdates()
if (result?.updateInfo.version) {
this.setState({
status: 'available',
version: result.updateInfo.version,
message: '发现新版本'
})
}
return { success: true, state: this.getState() }
} catch (error) {
const state = this.setState({
status: 'error',
message: error instanceof Error ? error.message : String(error)
})
return { success: false, state }
}
}
async download(): Promise<AppUpdateCheckResult> {
if (!isPackagedRuntime()) {
const state = this.setState({ status: 'unsupported', message: '开发模式不能下载更新' })
return { success: false, state }
}
try {
this.setState({ status: 'downloading', percent: 0 })
await autoUpdater.downloadUpdate()
return { success: true, state: this.getState() }
} catch (error) {
const state = this.setState({
status: 'error',
message: error instanceof Error ? error.message : String(error)
})
return { success: false, state }
}
}
install(): { success: boolean; error?: string } {
if (this.state.status !== 'downloaded') {
return { success: false, error: '更新包尚未下载完成' }
}
autoUpdater.quitAndInstall()
return { success: true }
}
handleState(callback: (state: AppUpdateState) => void): () => void {
this.listeners.add(callback)
callback(this.getState())
return () => this.listeners.delete(callback)
}
private listeners = new Set<(state: AppUpdateState) => void>()
private setState(patch: Partial<AppUpdateState>): AppUpdateState {
this.state = { ...this.state, ...patch, currentVersion: app.getVersion() }
const state = this.getState()
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('app-update:state', state)
}
for (const listener of this.listeners) listener(state)
return state
}
}
export const appUpdateService = new AppUpdateService()
+177 -24
View File
@@ -11,6 +11,19 @@ export interface CachedSelfInfo {
accountRoot: string
}
export interface CachedGroupSnapshot {
roomId: string
memberCount: number
members: {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}[]
}
interface CachedMessageBucket {
updatedAt: number
startTime?: number
@@ -26,11 +39,16 @@ interface BootstrapCacheFile {
self?: CachedSelfInfo
contacts?: Contact[]
messages?: Record<string, CachedMessageBucket>
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
}
const CACHE_VERSION = 1
const MAX_MESSAGE_BUCKETS = 24
const MAX_MESSAGES_PER_BUCKET = 1200
const MAX_MESSAGE_BUCKETS = 768
const MAX_MESSAGES_PER_BUCKET = 120
const WRITE_DEBOUNCE_MS = 300
const memoryCache = new Map<string, BootstrapCacheFile>()
const writeTimers = new Map<string, NodeJS.Timeout>()
const writeQueues = new Map<string, Promise<void>>()
function normalizeRoot(accountRoot?: string): string {
return String(accountRoot || '').trim()
@@ -43,27 +61,38 @@ function getCacheFile(accountRoot?: string): string {
.update(`${process.platform}:${normalizedRoot}`)
.digest('hex')
.slice(0, 16)
return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`)
return path.join(
app.getPath('userData'),
'cache',
'bootstrap',
`${process.platform}-${hash}.json`
)
}
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getCacheFile(normalizedRoot)
const cached = memoryCache.get(file)
if (cached) return cached
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 {
const result: BootstrapCacheFile = {
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 : {}
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
groupSnapshots:
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
}
memoryCache.set(file, result)
return result
} catch (error) {
console.warn('[BootstrapCache] read failed:', error)
return null
@@ -71,34 +100,82 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | 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)
}
const file = getCacheFile(cache.accountRoot)
memoryCache.set(file, cache)
const existingTimer = writeTimers.get(file)
if (existingTimer) clearTimeout(existingTimer)
writeTimers.set(
file,
setTimeout(() => {
writeTimers.delete(file)
const serialized = JSON.stringify(memoryCache.get(file) || cache)
const previous = writeQueues.get(file) || Promise.resolve()
const next = previous
.catch(() => undefined)
.then(async () => {
await fs.ensureDir(path.dirname(file))
await fs.writeFile(file, serialized, 'utf8')
})
.catch((error) => {
console.warn('[BootstrapCache] write failed:', error)
})
.finally(() => {
if (writeQueues.get(file) === next) writeQueues.delete(file)
})
writeQueues.set(file, next)
}, WRITE_DEBOUNCE_MS)
)
}
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
return (
readCacheFile(normalizedRoot) || {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {}
}
)
const existing = readCacheFile(normalizedRoot)
if (existing) return existing
const created: BootstrapCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {},
groupSnapshots: {}
}
memoryCache.set(getCacheFile(normalizedRoot), created)
return created
}
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
}
function cachedMessageIdentity(message: Message): string {
if (message.localId) return `local:${message.localId}`
if (message.serverId) return `server:${message.serverId}`
return `id:${message.id}`
}
function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
return items.some((message) => {
const content = message.contentData
if (content?.type === 'system' && content.raw) {
return (
/<weappinfo\b/i.test(content.raw) &&
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
)
}
if (
content?.type === 'share' &&
((content.typeVal === '3' && !content.url) || content.typeVal === '2001')
) {
return true
}
if (content?.type !== 'sticker') return false
const url = String(content.url || content.thumbUrl || '')
return /wxapp\.tenpay\.com\/mmpayhb|mp\.weixin\.qq\.com\/mp\/waerrpage/i.test(url)
})
}
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
const entries = Object.entries(messages)
if (entries.length <= MAX_MESSAGE_BUCKETS) return
@@ -144,7 +221,9 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
)
const nameByUsername = new Map(
cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
.filter(
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
)
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
)
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
@@ -179,7 +258,9 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
)
const nameByUsername = new Map(
(cache.contacts || [])
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
.filter(
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
)
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
)
cache.contacts = contacts.map((contact) => ({
@@ -223,6 +304,78 @@ export function getCachedMessages(
return bucket?.items || []
}
export function getCachedMessagePage(
accountRoot: string,
userMd5: string,
startTime?: number,
endTime?: number
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
const cache = readCacheFile(accountRoot)
const key = messageBucketKey(userMd5, startTime, endTime)
let bucket = cache?.messages?.[key]
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
const merged = new Map<string, Message>()
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
if (!cachedKey.startsWith(`${userMd5}:`)) continue
for (const message of candidate.items || []) {
merged.set(cachedMessageIdentity(message), message)
}
}
const migratedMessages = Array.from(merged.values())
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
.slice(-MAX_MESSAGES_PER_BUCKET)
if (migratedMessages.length > 0) {
bucket = {
updatedAt: Date.now(),
items: migratedMessages
}
cache.messages[key] = bucket
cache.updatedAt = Date.now()
pruneMessageBuckets(cache.messages)
writeCacheFile(cache)
}
}
return {
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []),
messages: bucket?.items || [],
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
}
}
export function saveCachedGroupSnapshot(
accountRoot: string,
userMd5: string,
snapshot: CachedGroupSnapshot
): void {
const cache = loadOrCreate(accountRoot)
if (!cache) return
cache.groupSnapshots ||= {}
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot }
cache.updatedAt = Date.now()
writeCacheFile(cache)
}
export function flushBootstrapCacheWritesSync(): void {
for (const [file, cache] of memoryCache) {
const timer = writeTimers.get(file)
if (timer) clearTimeout(timer)
writeTimers.delete(file)
try {
fs.ensureDirSync(path.dirname(file))
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
} catch (error) {
console.warn('[BootstrapCache] flush failed:', error)
}
}
}
export function clearBootstrapCache(): void {
for (const timer of writeTimers.values()) clearTimeout(timer)
writeTimers.clear()
writeQueues.clear()
memoryCache.clear()
}
export function saveCachedMessages(
accountRoot: string,
userMd5: string,
+73
View File
@@ -0,0 +1,73 @@
import { app, session } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import { clearBootstrapCache } from './bootstrap-cache'
import type { CacheClearScope, CacheSummary, CacheSummaryItem } from '../../shared/cache'
export type { CacheClearScope } from '../../shared/cache'
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
if (!fs.existsSync(directory)) return { sizeBytes: 0, fileCount: 0 }
let sizeBytes = 0
let fileCount = 0
const visit = (current: string): void => {
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const target = path.join(current, entry.name)
if (entry.isDirectory()) {
visit(target)
} else if (entry.isFile()) {
try {
sizeBytes += fs.statSync(target).size
fileCount += 1
} catch {
// A cache file can disappear while it is being inspected.
}
}
}
}
visit(directory)
return { sizeBytes, fileCount }
}
export function getCacheSummary(): CacheSummary {
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
const items: CacheSummaryItem[] = [
{
id: 'bootstrap',
label: '启动与聊天缓存',
description: '联系人、头像、群成员和最近聊天记录的本地副本。',
...bootstrap
},
{
id: 'electron',
label: '应用临时缓存',
description: 'Electron 页面资源缓存,清理后会自动重新生成。',
...electron
}
]
return {
items,
totalBytes: items.reduce((total, item) => total + item.sizeBytes, 0),
updatedAt: Date.now()
}
}
export async function clearCache(scope: CacheClearScope): Promise<CacheSummary> {
if (scope === 'bootstrap' || scope === 'all') {
clearBootstrapCache()
await fs.remove(BOOTSTRAP_CACHE_DIR)
}
if (scope === 'electron' || scope === 'all') {
await session.defaultSession.clearCache()
}
return getCacheSummary()
}
+169 -28
View File
@@ -1,5 +1,6 @@
import { WechatDb, WechatMessage } from '../wechat-db'
import {
parseImageBufferDataUrlFromRow,
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
@@ -8,6 +9,7 @@ import type {
DatabaseKeyValidationCode,
DatabaseKeyValidationResult
} from '../../shared/database-key'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
export function getCurrentKey(): string {
if (!dbRef) return ''
@@ -33,6 +35,8 @@ export interface FormattedContact {
md5: string
type: 'user' | 'group'
avatar?: string
wechatNickname?: string
remark?: string
}
export interface FormattedMessage {
@@ -48,9 +52,17 @@ export interface FormattedMessage {
contentData?: ReturnType<typeof parseMessageContent>
voiceDataUrl?: string
voiceDuration?: number
exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker'
exportShowAvatar?: boolean
exportMediaError?: string
exportAvatarUrl?: string
localId?: number
serverId?: string
createTime?: number
sessionId?: string
recalled?: boolean
recalledBy?: string
}
export interface GroupSnapshot {
@@ -126,29 +138,35 @@ export function listContacts(filter?: string): FormattedContact[] {
m_nsNickName: user.nickname || '未知用户',
md5,
type: isGroup ? 'group' : 'user',
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
wechatNickname: user.wechatNickname,
remark: user.remark
})
}
const chatTables = dbRef.getAllChatTables()
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'
})
// The session list already covers normal conversations. Only scan Chat_*
// tables as a recovery fallback when the session query returned nothing.
if (userList.length === 0) {
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
@@ -163,11 +181,12 @@ export function getContactAvatars(usernames: string[]): Record<string, string> {
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
}
export function listMessages(
function listSourceMessages(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
options?: { limit?: number },
rawMessagesOverride?: WechatMessage[]
): FormattedMessage[] {
if (!dbRef) return []
@@ -178,7 +197,8 @@ export function listMessages(
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)
const rawMessages =
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
console.log(
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
)
@@ -216,17 +236,62 @@ export function listMessages(
let contentData: ReturnType<typeof parseMessageContent> | undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const rawContent = String(content || '')
const isPatMessage =
/<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) ||
([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent))
if (isPatMessage) {
const system = parseMessageContent(content, 10000)
const patContent =
system.type === 'system'
? { ...system, pat: true }
: { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true }
contentData = patContent
content = patContent.content
displayType = '系统消息'
}
const inferredMsgType =
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)) {
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const parsed =
inferredMsgType === 47
const isQuotePayload = /<refermsg\b/i.test(content)
const hasStickerPayload =
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
/<type>\s*47\s*<\/type>/i.test(content)
const rowSticker =
inferredMsgType === 47 ||
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
? parseStickerMessageFromRow(msg, content)
: parseMessageContent(content, inferredMsgType)
: undefined
const parsedContent = parseMessageContent(content, inferredMsgType)
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
const redPacketUrl = rowStickerUrl || parsedShareUrl
const isRedPacketFallback =
(parsedContent.type === 'share' && parsedContent.typeVal === '2001') ||
/wxapp\.tenpay\.com\/mmpayhb/i.test(redPacketUrl)
const parsed: ReturnType<typeof parseMessageContent> =
parsedContent.type === 'miniProgram' || parsedContent.type === 'redPacket'
? parsedContent
: isRedPacketFallback
? {
type: 'redPacket',
title:
parsedContent.type === 'share' && parsedContent.title
? parsedContent.title
: '微信红包',
description:
parsedContent.type === 'share' && parsedContent.des
? parsedContent.des
: '恭喜发财,大吉大利',
url: redPacketUrl || undefined
}
: rowSticker?.type === 'sticker'
? rowSticker
: parsedContent
if (parsed.type === 'system') {
content = parsed.content
contentData = parsed
@@ -236,6 +301,13 @@ export function listMessages(
if (parsed.type === 'image') {
const imageDatName = parseImageDatNameFromRow(msg)
contentData = { ...parsed, datName: parsed.datName || imageDatName }
} else if (parsed.type === 'miniProgram') {
contentData = {
...parsed,
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
thumbDataUrl:
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
}
} else if (parsed.type !== 'system') {
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
@@ -245,6 +317,17 @@ export function listMessages(
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
}
if (parsed.type === 'quote') displayType = '引用消息'
if (parsed.type === 'sticker') displayType = '表情包'
if (parsed.type === 'miniProgram') displayType = '小程序'
if (parsed.type === 'redPacket') displayType = '微信红包'
if (parsed.type === 'share') {
if (parsed.typeVal === '5') displayType = '公众号链接'
if (parsed.typeVal === '6') displayType = '文件'
if (parsed.typeVal === '74') displayType = '文件发送中'
if (parsed.typeVal === '51') displayType = '视频号'
if (parsed.typeVal === '2000') displayType = '转账'
}
} catch {
// ignore parse errors
}
@@ -264,8 +347,12 @@ export function listMessages(
if (msgType === 34) content = '[语音消息]'
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
return {
id: msg.mesLocalID || Math.random().toString(),
id: recoveredFromRecallJournal
? `recovered:${msg.mesLocalID || msg.serverId || createTime}`
: msg.mesLocalID || Math.random().toString(),
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
isSender: isMine,
type: displayType,
@@ -276,7 +363,9 @@ export function listMessages(
senderId,
sessionId: username,
localId,
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
createTime,
recoveredFromRecallJournal,
contentData
}
})
@@ -287,6 +376,39 @@ export function listMessages(
return formatted
}
export function listMessages(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
): FormattedMessage[] {
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options)
if (!dbRef) return sourceMessages
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
recordRecallArchiveMessages(userMd5, username, sourceMessages)
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
}
export async function listMessagesAsync(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
): Promise<FormattedMessage[]> {
if (!dbRef) return []
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
const sourceMessages = listSourceMessages(
userMd5,
startTime,
endTime,
options,
rawMessages
)
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
recordRecallArchiveMessages(userMd5, username, sourceMessages)
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
}
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
if (!dbRef) return null
const wcdb4Client = dbRef.getWcdb4Client()
@@ -308,6 +430,25 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
return { roomId, memberCount: members.length, members }
}
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
if (!dbRef) return null
const wcdb4Client = dbRef.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = (await wcdb4Client.getGroupMembersAsync(roomId))
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
groupNickname: member.groupNickname || '',
wechatNickname: member.wechatNickname || '',
remark: member.remark || '',
avatar: member.m_nsHeadImgUrl || ''
}))
return { roomId, memberCount: members.length, members }
}
export function searchMessages(keyword: string): string | null {
if (!dbRef) return null
return dbRef.searchAllMessages(keyword)
@@ -17,7 +17,9 @@ import { isWechatRunning } from './wechat-process-status'
export async function inspectImageDecryptionStatus(
config: ImageKeyConfigResult
): Promise<ImageDecryptionStatus> {
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot
// 状态面板的"图片资源目录"始终等于当前识别到的微信账号根目录;
// 仅在微信未连接时回退到上次配置中的 resourceRoot,避免空白。
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot || ''
const imageDirectoryFound = hasImageDirectory(accountRoot)
const stickerCacheFound =
fs.existsSync(path.join(accountRoot, 'cache')) ||
@@ -65,7 +67,10 @@ export function testImageDecryption(
.reverse()
.find((message) => message.contentData?.type === 'image')
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息')
return failure(
'NO_IMAGE_MESSAGE',
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
)
}
const service = new ImageDecryptService(
@@ -74,26 +79,51 @@ export function testImageDecryption(
chat.getChatDb()?.getWcdb4Client()
)
const image = imageMessage.contentData
let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false })
// 测试时优先使用用户在下方"图片资源目录"输入框填写的目录;
// 找不到再退回默认 accountDir。
const testAccountDir = normalized.resourceRoot || undefined
let filePath = service.findImageFile(image.md5, image.datName, {
allowThumbnail: false,
accountDir: testAccountDir
})
if (!filePath)
filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true })
filePath = service.findImageFile(image.md5, image.datName, {
allowThumbnail: true,
accountDir: testAccountDir
})
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
const data = service.decryptImageToBase64(filePath)
if (!data) {
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。
return {
...failure('DECRYPT_FAILED', '无法解析媒体文件'),
fileFound: true
success: false,
code: 'DECRYPT_FAILED',
error: '无法解析媒体文件',
fileFound: false,
decrypted: false,
readable: false
}
}
const readable = data.startsWith('data:image/')
if (!readable) {
// 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat),
// 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。
return {
success: false,
code: 'DECRYPT_FAILED',
error: '图片解密结果不可读取',
fileFound: true,
decrypted: false,
readable: false,
isThumbnail: service.isThumbnailFile(filePath)
}
}
return {
success: readable,
code: readable ? undefined : 'DECRYPT_FAILED',
error: readable ? undefined : '图片解密结果不可读取',
success: true,
fileFound: true,
decrypted: true,
readable,
readable: true,
isThumbnail: service.isThumbnailFile(filePath)
}
} catch {
@@ -92,9 +92,10 @@ export class ImageKeyConfigService {
if (!result.success || !result.entry) {
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
}
// 注意:normalized.resourceRoot 不再写回 imageKeyRoot。
// 下方的"图片资源目录"输入框仅用于本次手动测试,不再污染状态面板上方显示。
saveSettings({
...settings,
imageKeyRoot: normalized.resourceRoot,
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false
@@ -106,7 +107,9 @@ export class ImageKeyConfigService {
encryptionAvailable: true,
source: 'secure-storage',
accountId: context.accountId,
resourceRoot: normalized.resourceRoot,
// 返回的 resourceRoot 始终是识别到的默认目录,与状态面板一致;
// 不返回 normalized.resourceRoot,避免把用户输入的测试目录当成状态写回。
resourceRoot: context.resourceRoot,
xorKey: result.entry.xorKey,
aesKey: result.entry.aesKey,
updatedAt: result.entry.updatedAt
+472
View File
@@ -0,0 +1,472 @@
import { app } from 'electron'
import crypto from 'crypto'
import fs from 'fs-extra'
import path from 'path'
import type { Message } from '../../shared/types'
type RecallRecord = {
targetIds: string[]
actor?: string
noticeId: string
createTime: number
}
type ArchivedMessage = Message & {
archivedAt: number
recallNotice?: boolean
}
type SessionArchive = {
username: string
updatedAt: number
messages: ArchivedMessage[]
recalls: RecallRecord[]
}
type RecallArchiveFile = {
version: 1
accountRoot: string
updatedAt: number
sessions: Record<string, SessionArchive>
}
const ARCHIVE_VERSION = 1
const MAX_MESSAGES_PER_SESSION = 3000
const MAX_RECALLS_PER_SESSION = 500
const WRITE_DEBOUNCE_MS = 250
let archive: RecallArchiveFile | null = null
let archivePath = ''
let writeTimer: NodeJS.Timeout | null = null
let writeQueue: Promise<void> = Promise.resolve()
function messageIdentity(message: Message): string {
if (message.recoveredFromRecallJournal) {
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
}
if (message.localId) return `local:${message.localId}`
if (message.serverId) return `server:${message.serverId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
function messageTargetIds(message: Message): string[] {
return Array.from(
new Set(
[
message.serverId,
message.localId ? String(message.localId) : '',
message.id ? String(message.id) : ''
].filter((value): value is string => Boolean(value))
)
)
}
function isRecallNotice(message: Message): boolean {
return message.contentData?.type === 'system' && Boolean(message.contentData.recall)
}
function matchesRecall(message: Message, recall: RecallRecord): boolean {
const messageIds = messageTargetIds(message)
return recall.targetIds.some((targetId) => messageIds.includes(targetId))
}
function inferRecallTarget(
messages: Iterable<ArchivedMessage>,
notice: Message,
actor?: string,
claimedTargetIds: Set<string> = new Set()
): ArchivedMessage | undefined {
const noticeTime = notice.createTime || 0
const expectsMine = /^(你|我)$/.test(String(actor || '').trim()) || /^你撤回/.test(notice.content)
const eligible = Array.from(messages)
.filter((message) => {
if (message.type === '系统消息' || message.from === 'system' || message.recallNotice)
return false
if (!message.recoveredFromRecallJournal) return false
if (messageTargetIds(message).some((targetId) => claimedTargetIds.has(targetId))) return false
if (noticeTime && message.createTime && message.createTime > noticeTime) return false
if (noticeTime && message.createTime && noticeTime - message.createTime > 180) return false
return true
})
.sort((left, right) => {
const timeDelta = (right.createTime || 0) - (left.createTime || 0)
if (timeDelta) return timeDelta
return (right.localId || 0) - (left.localId || 0)
})
const strict = eligible.find((message) => {
if (expectsMine) return message.isSender
if (!actor) return true
const names = [message.name, message.senderId].map((value) => String(value || '').trim())
return !names.some(Boolean) || names.includes(actor)
})
// Journal rows are captured at deletion time, so the fallback never points at
// an unrelated visible message merely because it happened to be nearby.
return strict || eligible[0]
}
function annotateFromRecall(message: Message, recalls: RecallRecord[]): Message {
if (isRecallNotice(message)) return message
const recall = recalls.find((item) => matchesRecall(message, item))
if (!recall) return message
const recalledByMe = /^(你|我)$/.test(String(recall.actor || '').trim())
return {
...message,
from: recalledByMe ? 'assistant' : message.from,
isSender: recalledByMe ? true : message.isSender,
recalled: true,
recalledBy: recall.actor
}
}
function scheduleWrite(): void {
if (!archive || !archivePath) return
if (writeTimer) clearTimeout(writeTimer)
writeTimer = setTimeout(() => {
writeTimer = null
if (!archive || !archivePath) return
const targetPath = archivePath
const serialized = JSON.stringify(archive)
writeQueue = writeQueue
.catch(() => undefined)
.then(async () => {
await fs.ensureDir(path.dirname(targetPath))
const temporaryPath = `${targetPath}.tmp`
await fs.writeFile(temporaryPath, serialized, 'utf8')
await fs.move(temporaryPath, targetPath, { overwrite: true })
})
.catch((error) => {
console.warn('[RecallArchive] write failed:', error)
})
}, WRITE_DEBOUNCE_MS)
}
function loadArchive(accountRoot: string): RecallArchiveFile {
const hash = crypto
.createHash('sha1')
.update(`${process.platform}:${accountRoot}`)
.digest('hex')
.slice(0, 16)
archivePath = path.join(app.getPath('userData'), 'recall-archive', `${hash}.json`)
try {
if (fs.existsSync(archivePath)) {
const stored = fs.readJsonSync(archivePath) as Partial<RecallArchiveFile>
if (
stored.version === ARCHIVE_VERSION &&
stored.accountRoot === accountRoot &&
stored.sessions &&
typeof stored.sessions === 'object'
) {
return stored as RecallArchiveFile
}
}
} catch (error) {
console.warn('[RecallArchive] read failed:', error)
}
return {
version: ARCHIVE_VERSION,
accountRoot,
updatedAt: Date.now(),
sessions: {}
}
}
export function configureRecallArchive(accountRoot: string): void {
const normalizedRoot = String(accountRoot || '').trim()
if (!normalizedRoot) {
archive = null
archivePath = ''
return
}
if (archive?.accountRoot === normalizedRoot) return
archive = loadArchive(normalizedRoot)
}
export function recordRecallArchiveMessages(
sessionMd5: string,
username: string,
messages: Message[]
): void {
if (!archive || !sessionMd5 || messages.length === 0) return
const bucket =
archive.sessions[sessionMd5] ||
({
username,
updatedAt: Date.now(),
messages: [],
recalls: []
} satisfies SessionArchive)
if (username) bucket.username = username
const byIdentity = new Map(bucket.messages.map((message) => [messageIdentity(message), message]))
let changed = false
const targetlessNoticeIds = new Set(
messages
.filter((message) => {
const recall =
message.contentData?.type === 'system' ? message.contentData.recall : undefined
return Boolean(recall && !(recall.targetIds?.length || recall.targetId))
})
.map(messageIdentity)
)
if (targetlessNoticeIds.size > 0) {
const retained = bucket.recalls.filter((record) => !targetlessNoticeIds.has(record.noticeId))
if (retained.length !== bucket.recalls.length) {
bucket.recalls = retained
changed = true
}
}
for (const sourceMessage of messages) {
const recallData =
sourceMessage.contentData?.type === 'system' ? sourceMessage.contentData.recall : undefined
if (recallData) {
let targetIds = Array.from(
new Set([...(recallData.targetIds || []), recallData.targetId || ''].filter(Boolean))
)
const noticeId = messageIdentity(sourceMessage)
const noticeTargetIds = new Set(messageTargetIds(sourceMessage))
const existingRecordIndex = bucket.recalls.findIndex((record) => record.noticeId === noticeId)
const existingRecord =
existingRecordIndex >= 0 ? bucket.recalls[existingRecordIndex] : undefined
const existingTargetsNotice =
existingRecord &&
existingRecord.targetIds.length > 0 &&
existingRecord.targetIds.every((targetId) => noticeTargetIds.has(targetId))
if (
targetIds.length === 0 &&
existingRecord &&
!existingTargetsNotice &&
!targetlessNoticeIds.has(noticeId)
) {
targetIds = existingRecord.targetIds
}
if (targetIds.length === 0) {
const claimedTargetIds = new Set(bucket.recalls.flatMap((record) => record.targetIds))
const inferred = inferRecallTarget(
byIdentity.values(),
sourceMessage,
recallData.actor,
claimedTargetIds
)
if (inferred) targetIds = messageTargetIds(inferred)
}
if (targetIds.length > 0) {
const nextRecord: RecallRecord = {
targetIds,
actor: recallData.actor,
noticeId,
createTime: sourceMessage.createTime || 0
}
const unchanged =
existingRecord &&
existingRecord.targetIds.length === targetIds.length &&
existingRecord.targetIds.every((targetId) => targetIds.includes(targetId)) &&
existingRecord.actor === nextRecord.actor
if (!unchanged) {
if (existingRecordIndex >= 0) bucket.recalls.splice(existingRecordIndex, 1, nextRecord)
else bucket.recalls.push(nextRecord)
changed = true
}
}
}
const annotated = annotateFromRecall(sourceMessage, bucket.recalls)
const next: ArchivedMessage = {
...annotated,
archivedAt: Date.now(),
recallNotice: isRecallNotice(sourceMessage)
}
const identity = messageIdentity(next)
const previous = byIdentity.get(identity)
if (
!previous ||
previous.recalled !== next.recalled ||
previous.content !== next.content ||
previous.serverId !== next.serverId
) {
byIdentity.set(identity, next)
changed = true
}
}
const recalls = bucket.recalls.slice(-MAX_RECALLS_PER_SESSION)
const nextMessages = Array.from(byIdentity.values())
.map((message) => annotateFromRecall(message, recalls) as ArchivedMessage)
.sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
})
.slice(-MAX_MESSAGES_PER_SESSION)
if (!changed && nextMessages.every((message, index) => message === bucket.messages[index])) return
bucket.messages = nextMessages
bucket.recalls = recalls
bucket.updatedAt = Date.now()
archive.sessions[sessionMd5] = bucket
archive.updatedAt = Date.now()
scheduleWrite()
}
export function mergeRecallArchiveMessages<T extends Message>(
sessionMd5: string,
sourceMessages: T[],
startTime?: number,
endTime?: number,
limit?: number
): T[] {
const bucket = archive?.sessions[sessionMd5]
if (!bucket) return sourceMessages
const merged = new Map<string, Message>()
for (const message of sourceMessages) {
const archived = bucket.messages.find(
(candidate) => messageIdentity(candidate) === messageIdentity(message)
)
merged.set(
messageIdentity(message),
annotateFromRecall(
archived?.recalled
? { ...message, recalled: true, recalledBy: archived.recalledBy }
: message,
bucket.recalls
)
)
}
for (const archived of bucket.messages) {
if (!archived.recalled && !archived.recallNotice) continue
const createTime = archived.createTime || 0
if (startTime && createTime < startTime) continue
if (endTime && createTime > endTime) continue
const identity = messageIdentity(archived)
if (!merged.has(identity)) merged.set(identity, archived)
}
const result = Array.from(merged.values()).sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
})
const visible = limit && result.length > limit ? result.slice(-limit) : result
return visible as T[]
}
export function extractChangedConversationMd5(payload: string): string[] {
const matches = String(payload || '').match(/(?:Chat_|chat_)?([a-f0-9]{32})/gi) || []
return Array.from(
new Set(
matches
.map((value) => value.replace(/^chat_/i, '').toLowerCase())
.filter((value) => /^[a-f0-9]{32}$/.test(value))
)
)
}
type ArchiveContact = {
md5: string
m_nsUsrName: string
type: 'user' | 'group'
activityKey?: string
}
export class RecallArchiveMonitor {
private pending = new Set<string>()
private activityBySession = new Map<string, string>()
private timer: NodeJS.Timeout | null = null
private stopped = false
constructor(
private readonly listContacts: () => ArchiveContact[],
private readonly loadMessages: (sessionMd5: string) => Message[]
) {}
seedAll(): void {
const groups = this.conversationContacts()
this.activityBySession = new Map(
groups.map((contact) => [contact.md5, String(contact.activityKey || '')])
)
const allowed = new Set(groups.map((contact) => contact.md5))
if (archive) {
let pruned = false
for (const sessionMd5 of Object.keys(archive.sessions)) {
if (allowed.has(sessionMd5)) continue
delete archive.sessions[sessionMd5]
pruned = true
}
if (pruned) {
archive.updatedAt = Date.now()
scheduleWrite()
}
}
}
handleDatabaseChange(payload: string): void {
// Ordinary message inserts are already visible in the main reader. The
// archive scan is only needed when the native event indicates a recall.
if (!/(recall|revoke|revokemsg|撤回)/i.test(String(payload || ''))) return
const groups = this.conversationContacts()
const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase()))
const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5))
const changedActivity = groups
.filter((contact) => {
const nextKey = String(contact.activityKey || '')
const previousKey = this.activityBySession.get(contact.md5)
this.activityBySession.set(contact.md5, nextKey)
return Boolean(nextKey && previousKey !== undefined && previousKey !== nextKey)
})
.map((contact) => contact.md5)
const targets = Array.from(new Set([...payloadTargets, ...changedActivity]))
if (targets.length > 0) {
console.log(`[RecallArchive] database change queued=${targets.length}`)
this.enqueue(targets)
}
}
private conversationContacts(): ArchiveContact[] {
return this.listContacts().filter(
(contact) =>
contact.m_nsUsrName &&
!contact.m_nsUsrName.startsWith('@placeholder') &&
contact.m_nsUsrName !== 'brandsessionholder' &&
contact.m_nsUsrName !== 'brandservicesessionholder'
)
}
stop(): void {
this.stopped = true
this.pending.clear()
if (this.timer) clearTimeout(this.timer)
this.timer = null
}
private enqueue(sessionMd5s: string[]): void {
if (this.stopped) return
for (const md5 of sessionMd5s) {
if (md5) this.pending.add(md5)
}
this.schedule()
}
private schedule(): void {
if (this.stopped || this.timer || this.pending.size === 0) return
this.timer = setTimeout(() => {
this.timer = null
const md5 = this.pending.values().next().value as string | undefined
if (!md5) return
this.pending.delete(md5)
try {
this.loadMessages(md5)
} catch (error) {
console.warn(`[RecallArchive] background scan failed md5=${md5}:`, error)
}
this.schedule()
}, 20)
}
}
+41 -39
View File
@@ -3,6 +3,22 @@ import fs from 'fs-extra'
import path from 'path'
import os from 'os'
/**
* 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到
* "...\\Documents\\xwechat_files"V4)。如果 xwechat_files 不存在则保留原值。
* 仅支持 WeChat 4.0:自动纠正用户机器上残留的旧路径。
*/
function redirectLegacyWeChatFilesToXwechat(candidate: string): string {
if (!candidate) return candidate
const normalized = candidate.replace(/[\\/]+$/, '')
const lowered = normalized.toLowerCase()
const legacyMarker = `${path.sep}wechat files`
if (!lowered.endsWith(legacyMarker)) return candidate
const redirected = `${normalized.slice(0, -legacyMarker.length)}${path.sep}xwechat_files`
if (fs.existsSync(redirected)) return redirected
return candidate
}
export interface AppSettings {
dbRoot: string
apiEnabled: boolean
@@ -12,8 +28,13 @@ export interface AppSettings {
imageXorKey: string
imageAesKey: string
imageKeyFallbackDisabled: boolean
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
appearanceTheme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
}
function getDefaultDbRoot(): string {
@@ -24,28 +45,20 @@ function getDefaultDbRoot(): string {
function getDefaultDbRootCandidates(home: string): string[] {
if (process.platform !== 'win32') {
// macOS 仅支持 WeChat 4.0 路径(xwechat_files
return [
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
]
}
// 仅支持 WeChat 4.0:剔除 V3 时代的 "WeChat Files" 目录,
// 只认 xwechat_files(含 Documents\ 和 AppData\Roaming\Tencent\ 两种合法位置)。
const candidates = [
...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)
}
@@ -68,32 +81,6 @@ function getWeflowDbPathCandidates(home: string): string[] {
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))
}
@@ -121,12 +108,17 @@ const DEFAULT_SETTINGS: AppSettings = {
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false,
recallProtectionEnabled: false,
debugEnabled: false,
autoLogin: ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
.toLowerCase()
),
autoLoginPreferenceSet: false
autoLoginPreferenceSet: false,
appearanceTheme: 'system',
compactMode: false,
showStartupProgress: true
}
const SETTINGS_FILE = path.join(
@@ -155,9 +147,19 @@ export function loadSettings(): AppSettings {
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
cache.dbRoot = getDefaultDbRoot()
}
if (!cache.imageKeyRoot) {
// 同步:imageKeyRoot 必须跟随 dbRoot 更新,
// 否则自动获取会扫错目录(旧 bug:状态面板显示 D 盘,自动获取扫 C 盘)。
if (!cache.imageKeyRoot || !isUsableDbRoot(cache.imageKeyRoot)) {
cache.imageKeyRoot = cache.dbRoot
}
// V4-only 兜底:如果 imageKeyRoot 指向旧的 "WeChat Files"V3 路径),
// 重定向到同一父目录下的 xwechat_filesV4)。
if (cache.imageKeyRoot) {
cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot)
}
if (cache.dbRoot) {
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
}
return cache
}
} catch (error) {
+2 -1
View File
@@ -1,6 +1,7 @@
import { app, shell } from 'electron'
import { existsSync, promises as fs } from 'fs'
import { dirname, join } from 'path'
import { isPackagedRuntime } from '../runtime-mode'
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
const GITHUB_URL =
@@ -23,7 +24,7 @@ function getSkillCandidates(): { path: string; source: 'development' | 'bundled'
join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH),
join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH)
]
return app.isPackaged
return isPackagedRuntime()
? bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
: [
{ path: developmentPath, source: 'development' as const },
+21 -13
View File
@@ -21,6 +21,18 @@ export class StickerService {
const normalizedMd5 = this.normalizeMd5(md5)
let url = String(cdnUrl || '').trim()
const cacheKey =
normalizedMd5 || (url ? crypto.createHash('md5').update(url).digest('hex') : '')
if (cacheKey) {
const cached = await this.readCached(cacheKey)
if (cached) return { success: true, data: cached }
}
if (normalizedMd5 && this.wcdb4Client) {
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
if (wechatCached) return { success: true, data: wechatCached }
}
if (!url && normalizedMd5 && this.wcdb4Client) {
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
if (!url) {
@@ -32,30 +44,26 @@ export class StickerService {
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 }
const resolvedCacheKey = cacheKey || crypto.createHash('md5').update(url).digest('hex')
if (normalizedMd5 && this.wcdb4Client) {
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
if (wechatCached) return { success: true, data: wechatCached }
}
const pending = downloadCache.get(cacheKey)
const pending = downloadCache.get(resolvedCacheKey)
if (pending) return pending
const task = this.downloadToDataUrl(url, cacheKey)
downloadCache.set(cacheKey, task)
const task = this.downloadToDataUrl(url, resolvedCacheKey)
downloadCache.set(resolvedCacheKey, task)
try {
return await task
} finally {
downloadCache.delete(cacheKey)
downloadCache.delete(resolvedCacheKey)
}
}
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')]
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}`)
+104
View File
@@ -0,0 +1,104 @@
import fs from 'fs-extra'
import path from 'path'
import crypto from 'crypto'
import type { Wcdb4Client } from './wcdb4-client'
type VideoAsset = {
filePath: string
posterPath?: string
}
export class VideoAssetService {
private readonly urlTokens = new Map<string, string>()
private index: Map<string, VideoAsset> | null = null
constructor(private readonly client: Wcdb4Client) {}
resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } {
const candidates = Array.from(
new Set(
hashes
.map((value) =>
String(value || '')
.trim()
.toLowerCase()
)
.filter((value) => /^[a-f0-9]{32}$/.test(value))
)
)
if (candidates.length === 0) return { success: false, error: '视频标识为空' }
const hardlinkDb = path.join(
this.client.getAccountRoot(),
'db_storage',
'hardlink',
'hardlink.db'
)
const lookupKeys = [...candidates]
if (fs.existsSync(hardlinkDb)) {
for (const hash of candidates) {
const resolved = this.client.resolveVideoHardlink(hash, hardlinkDb)?.resolved_md5
if (resolved) lookupKeys.unshift(String(resolved).trim().toLowerCase())
}
}
const index = this.getIndex()
for (const key of lookupKeys) {
const asset = index.get(key) || index.get(`${key}_raw`)
if (!asset) continue
return {
success: true,
url: this.createUrl(asset.filePath),
poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined
}
}
return { success: false, error: '本地未找到该视频文件' }
}
pathForToken(token: string): string | undefined {
const filePath = this.urlTokens.get(token)
if (!filePath || !fs.existsSync(filePath)) return undefined
return filePath
}
private createUrl(filePath: string): string {
const token = crypto.randomBytes(18).toString('hex')
this.urlTokens.set(token, filePath)
if (this.urlTokens.size > 500) {
const first = this.urlTokens.keys().next().value
if (first) this.urlTokens.delete(first)
}
return `wxe-media://local/${token}`
}
private getIndex(): Map<string, VideoAsset> {
if (this.index) return this.index
const result = new Map<string, VideoAsset>()
const root = path.join(this.client.getAccountRoot(), 'msg', 'video')
if (!fs.existsSync(root)) {
this.index = result
return result
}
for (const month of fs.readdirSync(root)) {
const monthPath = path.join(root, month)
if (!fs.statSync(monthPath).isDirectory()) continue
for (const name of fs.readdirSync(monthPath)) {
const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name)
if (!match) continue
const key = `${match[1].toLowerCase()}${match[2] || ''}`
const fullPath = path.join(monthPath, name)
const existing = result.get(key) || { filePath: '' }
if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath
else if (!existing.posterPath) existing.posterPath = fullPath
result.set(key, existing)
}
}
for (const [key, asset] of result) {
if (!asset.filePath) result.delete(key)
}
this.index = result
return result
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { Wcdb4Client } from './wcdb4-client'
import { isPackagedRuntime } from './runtime-mode'
export class VoiceService {
private wcdb4Client: Wcdb4Client
@@ -101,7 +102,7 @@ export class VoiceService {
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
try {
let wasmPath: string
if (app.isPackaged) {
if (isPackagedRuntime()) {
wasmPath = join(
process.resourcesPath,
'app.asar.unpacked',
+637 -84
View File
@@ -10,11 +10,14 @@ export interface Wcdb4Session {
username: string
nickname: string
avatar?: string
wechatNickname?: string
remark?: string
raw: Record<string, unknown>
}
export interface Wcdb4Message {
mesLocalID: string
serverId?: string
mesDes: number
messageType: string
msgCreateTime: string
@@ -29,6 +32,11 @@ export interface Wcdb4MessageQueryOptions {
limit?: number
}
type Wcdb4MessageStore = {
tableName: string
dbPath: string
}
export interface Wcdb4GroupMember {
m_nsUsrName: string
nickname: string
@@ -44,6 +52,11 @@ export interface Wcdb4ImageHardlink {
[key: string]: unknown
}
export interface Wcdb4VideoHardlink {
resolved_md5?: string
[key: string]: unknown
}
type KoffiModule = {
load: (libraryPath: string) => KoffiLibrary
decode: (ptr: unknown, type: string, length: number) => string
@@ -55,6 +68,8 @@ type KoffiModule = {
// library reference for every Wcdb4Client instance.
let wcdbBootstrapLib: KoffiLibrary | null = null
let wcdbBootstrapAsyncPromise: Promise<KoffiLibrary> | null = null
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
if (wcdbBootstrapLib) return wcdbBootstrapLib
@@ -128,8 +143,82 @@ export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string):
return lib
}
export function bootstrapWcdbNativeAsync(
libPath?: string,
libDirOverride?: string
): Promise<KoffiLibrary> {
if (wcdbBootstrapLib) return Promise.resolve(wcdbBootstrapLib)
if (wcdbBootstrapAsyncPromise) return wcdbBootstrapAsyncPromise
wcdbBootstrapAsyncPromise = (async () => {
const koffi = nodeRequire('koffi') as KoffiModule
const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary()
const libDir = libDirOverride || path.dirname(resolvedLibPath)
for (const name of process.platform === 'win32'
? ['WCDB.dll', 'SDL2.dll']
: process.platform === 'darwin'
? ['libWCDB.dylib']
: []) {
const preloadPath = path.join(libDir, name)
if (!fs.existsSync(preloadPath)) continue
try {
koffi.load(preloadPath)
} catch {
// The main library may still resolve its dependencies through the loader.
}
}
const lib = koffi.load(resolvedLibPath)
const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as (
resourcePath: string
) => number
const resourceRoots = Array.from(
new Set(
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter(
Boolean
)
)
)
let initOk = false
for (const resourceRoot of resourceRoots) {
try {
if (Number(initProtection(resourceRoot)) === 0) {
initOk = true
break
}
} catch {
// Try the next resource root.
}
}
if (initOk) {
const wcdbInit = lib.func('int32 wcdb_init()') as KoffiAsyncFunction
await new Promise<void>((resolve, reject) => {
wcdbInit.async((error: unknown, result: unknown) => {
if (error) {
reject(error)
return
}
if (Number(result) !== 0) {
console.warn(`[WCDB4] async wcdb_init rc=${Number(result)}`)
}
resolve()
})
})
}
wcdbBootstrapLib = lib
return lib
})().catch((error) => {
wcdbBootstrapAsyncPromise = null
throw error
})
return wcdbBootstrapAsyncPromise
}
type KoffiLibrary = {
func: (signature: string) => (...args: unknown[]) => unknown
func: (signature: string) => KoffiAsyncFunction
}
type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
async: (...args: unknown[]) => void
}
type WcdbVoidOut = [unknown]
@@ -157,6 +246,7 @@ export class Wcdb4Client {
private wcdbOpenAccount:
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
| null = null
private wcdbOpenAccountAsync: KoffiAsyncFunction | null = null
private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null
private wcdbFreeString: ((ptr: unknown) => void) | null = null
private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null
@@ -216,6 +306,9 @@ export class Wcdb4Client {
private wcdbResolveImageHardlink:
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
| null = null
private wcdbResolveVideoHardlink:
| ((handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number)
| null = null
private wcdbGetEmoticonCdnUrl:
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
| null = null
@@ -296,24 +389,17 @@ export class Wcdb4Client {
private static getDefaultRootCandidates(): string[] {
const home = os.homedir()
if (process.platform === 'win32') {
// 仅支持 WeChat 4.0:只认 xwechat_filesV3 时代的 "WeChat Files" 不再加入候选
const candidates = [
...Wcdb4Client.getWeflowDbPathCandidates(home),
path.join(home, 'Documents', 'WeChat Files'),
path.join(home, 'Documents', 'xwechat_files'),
path.join(home, 'WeChat Files'),
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
]
for (const drive of Wcdb4Client.getWindowsDrives()) {
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
for (const child of Wcdb4Client.listDirectories(`${drive}:\\`)) {
candidates.push(path.join(child, 'xwechat_files'))
candidates.push(path.join(child, 'WeChat Files'))
}
}
return Array.from(new Set(candidates))
}
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
return [
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
]
}
private static getWeflowDbPathCandidates(home: string): string[] {
@@ -335,35 +421,11 @@ export class Wcdb4Client {
return candidates
}
private static getWindowsDrives(): string[] {
const drives: string[] = []
for (let code = 67; code <= 90; code += 1) {
const drive = String.fromCharCode(code)
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
}
return drives
}
private static listDirectories(root: string): string[] {
try {
return fs
.readdirSync(root)
.map((name) => path.join(root, name))
.filter((candidate) => {
try {
return fs.statSync(candidate).isDirectory()
} catch {
return false
}
})
} catch {
return []
}
}
private static hasDbStorage(candidate: string): boolean {
try {
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
return (
fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
)
} catch {
return false
}
@@ -443,6 +505,39 @@ export class Wcdb4Client {
}
}
async openAsync(): Promise<void> {
this.loadNativeLibrary()
if (!this.wcdbOpenAccountAsync) {
throw new Error('WCDB 4.0 native open async interface unavailable')
}
const handleOut: WcdbHandleOut = [0]
const openResult = await new Promise<number>((resolve, reject) => {
this.wcdbOpenAccountAsync!.async(
this.sessionDbPath,
this.key,
handleOut,
(error: unknown, result: unknown) => {
if (error) reject(error)
else resolve(Number(result))
}
)
})
if (openResult !== 0 || handleOut[0] <= 0) {
throw new Error(
`wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}`
)
}
this.handle = handleOut[0]
if (this.wcdbSetMyWxid) {
try {
this.wcdbSetMyWxid(this.handle, this.wxid)
} catch {
// Optional helper. Failure does not block message reads.
}
}
}
close(): void {
this.stopMonitor()
if (this.handle === null || !this.wcdbShutdown) return
@@ -620,6 +715,11 @@ export class Wcdb4Client {
return this.cachedSessions
}
invalidateSessionCache(): void {
this.cachedSessions = null
this.cachedChatTables = null
}
getChatTables(): { name: string; db_number: string }[] {
if (this.cachedChatTables) return this.cachedChatTables
const sessions =
@@ -648,10 +748,12 @@ export class Wcdb4Client {
try {
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
if (cursorMessages) {
const recoveredMessages = this.readRecallJournal(username, startTime, endTime)
const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows)
console.log(
`[WCDB4] getMessages cursor ok username=${username} rows=${cursorMessages.length} cost=${Date.now() - startedAt}ms`
`[WCDB4] getMessages cursor ok username=${username} rows=${mergedMessages.length} recovered=${recoveredMessages.length} cost=${Date.now() - startedAt}ms`
)
return cursorMessages
return mergedMessages
}
} catch (error) {
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
@@ -680,10 +782,7 @@ export class Wcdb4Client {
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
)
} catch (error) {
console.warn(
`[WCDB4] get_messages failed username=${username} offset=${offset}:`,
error
)
console.warn(`[WCDB4] get_messages failed username=${username} offset=${offset}:`, error)
break
}
const batch = Array.isArray(rows) ? rows : []
@@ -710,6 +809,21 @@ export class Wcdb4Client {
return messages
}
async getMessagesAsync(
username: string,
startTime?: number,
endTime?: number,
options: Wcdb4MessageQueryOptions = {}
): Promise<Wcdb4Message[]> {
const startedAt = Date.now()
const maxRows = this.normalizeMessageLimit(options.limit)
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
console.log(
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
)
return messages
}
private readSessionRows(): Record<string, unknown>[] {
if (!this.wcdbGetSessions) return []
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
@@ -726,17 +840,9 @@ export class Wcdb4Client {
): Wcdb4Message[] {
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
let tables: { tableName: string; dbPath: string }[] = []
let tables: Wcdb4MessageStore[] = []
try {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetMessageTableStats!(handle, username, outJson)
)
tables = (Array.isArray(rows) ? rows : [])
.map((row) => ({
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
}))
.filter((row) => row.tableName && row.dbPath)
tables = this.listMessageStores(username)
} catch (error) {
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
return []
@@ -771,6 +877,172 @@ export class Wcdb4Client {
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
}
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
const stores = new Map<string, Wcdb4MessageStore>()
for (const username of this.uniq(usernames)) {
try {
for (const store of this.listMessageStores(username)) {
stores.set(`${store.dbPath}\u0000${store.tableName}`, store)
}
} catch (error) {
console.warn(`[WCDB4] recall journal table discovery failed username=${username}:`, error)
}
}
let installed = 0
let failed = 0
for (const store of stores.values()) {
try {
const columns = this.readMessageColumns(store)
if (columns.length === 0) throw new Error('消息表没有可归档列')
this.ensureRecallJournalTable(store, columns)
this.createRecallJournalTrigger(store, columns)
installed += 1
} catch (error) {
failed += 1
console.warn(
`[WCDB4] recall journal install failed db=${store.dbPath} table=${store.tableName}:`,
error
)
}
}
return { installed, failed }
}
private listMessageStores(username: string): Wcdb4MessageStore[] {
if (!this.wcdbGetMessageTableStats) return []
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetMessageTableStats!(handle, username, outJson)
)
return (Array.isArray(rows) ? rows : [])
.map((row) => ({
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
}))
.filter((row) => row.tableName && row.dbPath)
}
private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record<string, unknown>[] {
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!(handle, 'message', store.dbPath, sql, outJson)
)
return Array.isArray(rows) ? rows : []
}
private readMessageColumns(store: Wcdb4MessageStore): { name: string; declaration: string }[] {
const rows = this.executeMessageSql(
store,
`PRAGMA table_info(${this.quoteSqlIdentifier(store.tableName)})`
)
return rows
.map((row) => {
const name = this.pickString(row, ['name'])
const type = this.pickString(row, ['type']).toUpperCase()
const declaration = /^(INTEGER|REAL|TEXT|BLOB|NUMERIC)$/.test(type) ? type : 'BLOB'
return { name, declaration }
})
.filter((column) => column.name)
}
private ensureRecallJournalTable(
store: Wcdb4MessageStore,
columns: { name: string; declaration: string }[]
): void {
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
const definitions = columns
.map((column) => `${this.quoteSqlIdentifier(column.name)} ${column.declaration}`)
.join(', ')
this.executeMessageSql(
store,
`CREATE TABLE IF NOT EXISTS ${journal} (${definitions}, "_wxe_source_table" TEXT NOT NULL, "_wxe_captured_at" INTEGER NOT NULL)`
)
}
private createRecallJournalTrigger(
store: Wcdb4MessageStore,
columns: { name: string; declaration: string }[]
): void {
const table = this.quoteSqlIdentifier(store.tableName)
const triggerName = this.quoteSqlIdentifier(
`_wxe_capture_${crypto.createHash('sha1').update(store.tableName).digest('hex').slice(0, 16)}`
)
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
const targetColumns = [
...columns.map((column) => this.quoteSqlIdentifier(column.name)),
'"_wxe_source_table"',
'"_wxe_captured_at"'
].join(', ')
const sourceValues = [
...columns.map((column) => `OLD.${this.quoteSqlIdentifier(column.name)}`),
`'${store.tableName.replace(/'/g, "''")}'`,
`CAST(strftime('%s', 'now') AS INTEGER)`
].join(', ')
this.executeMessageSql(
store,
`CREATE TRIGGER IF NOT EXISTS ${triggerName} BEFORE DELETE ON ${table} BEGIN INSERT INTO ${journal} (${targetColumns}) VALUES (${sourceValues}); END`
)
}
private readRecallJournal(
username: string,
startTime?: number,
endTime?: number
): Wcdb4Message[] {
const recoveredRows: Record<string, unknown>[] = []
for (const store of this.listMessageStores(username)) {
try {
const begin = this.normalizeTimestamp(startTime || 0)
const end = this.normalizeTimestamp(endTime || 0)
const where = [
`"_wxe_source_table" = '${store.tableName.replace(/'/g, "''")}'`,
begin > 0 ? `"create_time" >= ${begin}` : '',
end > 0 ? `"create_time" <= ${end}` : ''
].filter(Boolean)
const rows = this.executeMessageSql(
store,
`SELECT *, 1 AS "_wxe_recovered" FROM ${this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))} WHERE ${where.join(' AND ')} ORDER BY "create_time" ASC LIMIT 500`
)
recoveredRows.push(...rows)
} catch {
// The journal is optional until installation has completed for this store.
}
}
return this.finalizeMessages(username, recoveredRows, startTime, endTime)
}
private mergeMessageRows(
current: Wcdb4Message[],
recovered: Wcdb4Message[],
limit?: number
): Wcdb4Message[] {
const merged = new Map<string, Wcdb4Message>()
for (const message of [...recovered, ...current]) {
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
const identity = message.mesLocalID
? `local:${message.mesLocalID}`
: message.serverId
? `server:${message.serverId}`
: `${message.msgCreateTime}:${message.msgContent}`
const key = recoveredRow ? `recovered:${identity}` : identity
merged.set(key, message)
}
const messages = Array.from(merged.values()).sort(
(left, right) =>
Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) ||
Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0)
)
return limit && messages.length > limit ? messages.slice(-limit) : messages
}
private recallJournalTableName(messageTableName: string): string {
return `_wxe_recall_journal_${crypto
.createHash('sha1')
.update(messageTableName)
.digest('hex')
.slice(0, 16)}`
}
getMyAvatarUrl(): string | undefined {
const candidates = this.getMyUsernameCandidates()
this.hydrateAvatarUrls(candidates)
@@ -888,26 +1160,25 @@ export class Wcdb4Client {
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
return visibleMessages
.map((message) => {
if (!message.sender) return message
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
const shouldPrefixSender =
username.endsWith('@chatroom') &&
message.mesDes === 1 &&
message.sender &&
message.msgContent &&
!message.msgContent.startsWith(`${message.sender}:`)
return {
...message,
senderNickname,
senderAvatar,
msgContent: shouldPrefixSender
? `${message.sender}:\n${message.msgContent}`
: message.msgContent
}
})
return visibleMessages.map((message) => {
if (!message.sender) return message
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
const shouldPrefixSender =
username.endsWith('@chatroom') &&
message.mesDes === 1 &&
message.sender &&
message.msgContent &&
!message.msgContent.startsWith(`${message.sender}:`)
return {
...message,
senderNickname,
senderAvatar,
msgContent: shouldPrefixSender
? `${message.sender}:\n${message.msgContent}`
: message.msgContent
}
})
}
private normalizeMessageLimit(limit?: number): number | undefined {
@@ -947,11 +1218,7 @@ export class Wcdb4Client {
'contactRemark',
'contact_remark'
])
const memberNickname = this.pickString(row, [
'displayName',
'display_name',
'name'
])
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
@@ -965,8 +1232,7 @@ export class Wcdb4Client {
return {
m_nsUsrName: username,
nickname:
groupNicknames.get(username) || remark || wechatNickname || memberNickname,
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
groupNickname: groupNicknames.get(username) || '',
wechatNickname: wechatNickname || memberNickname,
remark,
@@ -998,6 +1264,140 @@ export class Wcdb4Client {
}
}
private async getMessagesByCursorAsync(
username: string,
startTime?: number,
endTime?: number,
limit?: number
): Promise<Wcdb4Message[]> {
if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return []
const handle = this.ensureHandle()
const batchSize = limit ? Math.min(500, limit) : 1000
const cursorOut: WcdbHandleOut = [0]
const begin = this.normalizeTimestamp(startTime || 0)
const end = this.normalizeTimestamp(endTime || 0)
const ascending = limit ? 0 : 1
const openResult = await this.callAsyncCode(
this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction,
handle,
username,
batchSize,
ascending,
begin,
end,
cursorOut
)
if (openResult !== 0 || cursorOut[0] <= 0) return []
const cursor = cursorOut[0]
const allRows: Record<string, unknown>[] = []
try {
while (true) {
const outJson: WcdbVoidOut = [null]
const outHasMore: [number] = [0]
const fetchResult = await this.callAsyncCode(
this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction,
handle,
cursor,
outJson,
outHasMore
)
if (fetchResult !== 0 || !outJson[0]) break
try {
const json = this.koffi!.decode(outJson[0], 'char', -1)
const batch = JSON.parse(json) as Record<string, unknown>[]
if (Array.isArray(batch)) allRows.push(...batch)
} finally {
this.wcdbFreeString?.(outJson[0])
}
if (!outHasMore[0] || (limit && allRows.length >= limit)) break
}
} finally {
try {
this.wcdbCloseMessageCursor?.(handle, cursor)
} catch {
// Best-effort cursor cleanup.
}
}
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
}
async getGroupMembersAsync(chatroomId: string): Promise<Wcdb4GroupMember[]> {
if (!this.wcdbGetGroupMembers || !chatroomId) return []
try {
const groupNicknames = await this.getGroupNicknamesAsync(chatroomId)
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
chatroomId
)
const members = (Array.isArray(rows) ? rows : []).map((row) => {
const username = this.pickString(row, [
'username',
'userName',
'user_name',
'member_username',
'm_nsUsrName'
])
const wechatNickname = this.pickString(row, [
'nickname',
'nickName',
'wechatNickname',
'wechat_nickname',
'm_nsNickName'
])
const remark = this.pickString(row, [
'remark',
'remarkName',
'remark_name',
'contactRemark',
'contact_remark'
])
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
'headImgUrl',
'm_nsHeadImgUrl'
])
if (username && avatar) this.avatarCache.set(username, avatar)
return {
m_nsUsrName: username,
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
groupNickname: groupNicknames.get(username) || '',
wechatNickname: wechatNickname || memberNickname,
remark,
m_nsHeadImgUrl: avatar
}
})
const missingNames = members
.filter((member) => !member.nickname)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
const missingAvatars = members
.filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
await Promise.all([
this.hydrateDisplayNamesAsync(missingNames),
this.hydrateAvatarUrlsAsync(missingAvatars)
])
return members.map((member) => ({
...member,
nickname:
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
wechatNickname:
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
} catch (error) {
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
return []
}
}
getGroupNicknames(chatroomId: string): Map<string, string> {
const cached = this.groupNicknameCache.get(chatroomId)
if (cached) return cached
@@ -1026,6 +1426,28 @@ export class Wcdb4Client {
return nicknames
}
private async getGroupNicknamesAsync(chatroomId: string): Promise<Map<string, string>> {
const cached = this.groupNicknameCache.get(chatroomId)
if (cached) return cached
const nicknames = new Map<string, string>()
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
const rows = await this.callJsonAsync<
Record<string, string> | Record<string, unknown>[]
>(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId)
this.readStringMap(rows, [
'nickname',
'nickName',
'displayName',
'display_name',
'groupNickname',
'group_nickname',
'name'
]).forEach((nickname, username) => nicknames.set(username, nickname))
this.groupNicknameCache.set(chatroomId, nicknames)
return nicknames
}
async getVoiceData(
sessionId: string,
createTime: number,
@@ -1105,6 +1527,23 @@ export class Wcdb4Client {
}
}
resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null {
if (!this.wcdbResolveVideoHardlink) return null
const normalizedMd5 = String(md5 || '')
.trim()
.toLowerCase()
if (!/^[a-f0-9]{32}$/.test(normalizedMd5) || !dbPath) return null
try {
return this.callJson<Wcdb4VideoHardlink>((handle, outJson) =>
this.wcdbResolveVideoHardlink!(handle, normalizedMd5, dbPath, outJson)
)
} catch (error) {
console.warn('[WCDB4] resolve video hardlink failed:', error)
return null
}
}
resolveEmoticonCdnUrl(md5: string): string | undefined {
if (!this.wcdbGetEmoticonCdnUrl) {
console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`)
@@ -1196,9 +1635,11 @@ export class Wcdb4Client {
}
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
this.wcdbOpenAccount = lib.func(
const openAccount = lib.func(
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
this.wcdbOpenAccount = openAccount
this.wcdbOpenAccountAsync = openAccount as unknown as KoffiAsyncFunction
this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void
this.wcdbGetSessions = lib.func(
'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)'
@@ -1318,6 +1759,14 @@ export class Wcdb4Client {
this.wcdbResolveImageHardlink = null
}
try {
this.wcdbResolveVideoHardlink = lib.func(
'int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)'
) as (handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number
} catch {
this.wcdbResolveVideoHardlink = null
}
try {
this.wcdbGetEmoticonCdnUrl = lib.func(
'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)'
@@ -1480,6 +1929,41 @@ export class Wcdb4Client {
}
}
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
const handle = this.ensureHandle()
const outJson: WcdbVoidOut = [null]
return new Promise<T>((resolve, reject) => {
fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => {
if (error) {
reject(error)
return
}
const code = Number(result)
if (code !== 0 || !outJson[0]) {
reject(new Error(`WCDB async call failed, code: ${code}`))
return
}
try {
const json = this.koffi!.decode(outJson[0], 'char', -1)
resolve(JSON.parse(json) as T)
} catch (decodeError) {
reject(decodeError)
} finally {
this.wcdbFreeString?.(outJson[0])
}
})
})
}
private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<number> {
return new Promise<number>((resolve, reject) => {
fn.async(...args, (error: unknown, result: unknown) => {
if (error) reject(error)
else resolve(Number(result))
})
})
}
private ensureHandle(): number {
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
return this.handle
@@ -1506,7 +1990,21 @@ export class Wcdb4Client {
'remark',
'name'
])
return { username, nickname, raw: row }
const wechatNickname = this.pickString(row, [
'wechatNickname',
'wechat_nickname',
'nickname',
'nickName',
'name'
])
const remark = this.pickString(row, [
'remark',
'remarkName',
'remark_name',
'contactRemark',
'contact_remark'
])
return { username, nickname, wechatNickname, remark, raw: row }
}
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
@@ -1552,6 +2050,19 @@ export class Wcdb4Client {
'mesLocalID',
'id'
])
const serverId = this.pickString(row, [
'server_id',
'serverId',
'svr_id',
'svrId',
'msg_svr_id',
'msgSvrId',
'message_id',
'messageId',
'new_msg_id',
'newMsgId',
'WCDB_CT_server_id'
])
const messageType = this.pickString(row, [
'local_type',
'localType',
@@ -1573,6 +2084,7 @@ export class Wcdb4Client {
return {
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
serverId: serverId || undefined,
mesDes: isSend ? 0 : 1,
messageType: messageType || '1',
msgCreateTime: String(createTime),
@@ -1616,6 +2128,26 @@ export class Wcdb4Client {
}
}
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
if (!this.wcdbGetDisplayNames) return
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
if (missing.length === 0) return
try {
const rows = await this.callJsonAsync<
Record<string, string> | Record<string, unknown>[]
>(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing))
this.readStringMap(rows, [
'nickname',
'displayName',
'display_name',
'remark',
'name'
]).forEach((name, username) => this.displayNameCache.set(username, name))
} catch {
// Names are optional; usernames remain usable.
}
}
private hydrateAvatarUrls(usernames: string[]): void {
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
if (missing.length === 0) return
@@ -1650,6 +2182,27 @@ export class Wcdb4Client {
}
}
private async hydrateAvatarUrlsAsync(usernames: string[]): Promise<void> {
if (!this.wcdbGetAvatarUrls) return
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
if (missing.length === 0) return
try {
const rows = await this.callJsonAsync<
Record<string, string> | Record<string, unknown>[]
>(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing))
this.readStringMap(rows, [
'avatarUrl',
'avatar_url',
'headImgUrl',
'm_nsHeadImgUrl',
'big_head_img_url',
'small_head_img_url'
]).forEach((avatar, username) => this.avatarCache.set(username, avatar))
} catch {
// Avatars are optional.
}
}
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
const result = new Map<string, string>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
+44 -19
View File
@@ -4,6 +4,8 @@ export interface UserContact {
m_nsUsrName: string
nickname: string
avatar?: string
wechatNickname?: string
remark?: string
}
export interface WechatMessage {
@@ -33,20 +35,12 @@ export interface GroupMemberInfo {
export class WechatDb {
private wcdb4Client: Wcdb4Client
private chatMd5ToUsername = new Map<string, string>()
private chatTableMappingLoaded = false
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)))
}
})
const client = new Wcdb4Client(rawKey, accountRoot)
await client.openAsync()
return new WechatDb(rawKey, accountRoot, client)
}
constructor(
@@ -56,15 +50,25 @@ export class WechatDb {
initialChatTables?: { name: string; db_number: string }[]
) {
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
const client =
clientOverride || new Wcdb4Client(rawKey, accountRoot)
const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
if (!clientOverride) client.open()
this.wcdb4Client = client
for (const table of initialChatTables || client.getChatTables()) {
for (const table of initialChatTables || []) {
if (table.name.startsWith('Chat_')) {
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
}
}
this.chatTableMappingLoaded = Boolean(initialChatTables)
}
private ensureChatTableMapping(): void {
if (this.chatTableMappingLoaded) return
for (const table of this.wcdb4Client.getChatTables()) {
if (table.name.startsWith('Chat_')) {
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
}
}
this.chatTableMappingLoaded = true
}
public getUserList(nicknameFilter?: string): UserContact[] {
@@ -74,7 +78,9 @@ export class WechatDb {
.map((session) => ({
m_nsUsrName: session.username,
nickname: session.nickname || session.username,
avatar: session.avatar
avatar: session.avatar,
wechatNickname: session.wechatNickname,
remark: session.remark
}))
.filter((contact) => {
if (!keyword) return true
@@ -109,6 +115,7 @@ export class WechatDb {
}
public getGroupMembersForChat(userMd5: string): Record<string, string> {
this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5)
if (!username || !username.endsWith('@chatroom')) return {}
@@ -124,9 +131,8 @@ export class WechatDb {
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
if (!chatroomId) return null
return (
this.wcdb4Client
.getGroupMembers(chatroomId)
.find((member) => member.m_nsUsrName === wxid) || null
this.wcdb4Client.getGroupMembers(chatroomId).find((member) => member.m_nsUsrName === wxid) ||
null
)
}
@@ -152,6 +158,7 @@ export class WechatDb {
endTime?: number,
options?: Wcdb4MessageQueryOptions
): WechatMessage[] {
this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
@@ -160,6 +167,24 @@ export class WechatDb {
}))
}
public async getUserMessagesAsync(
userMd5: string,
startTime?: number,
endTime?: number,
options?: Wcdb4MessageQueryOptions
): Promise<WechatMessage[]> {
this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
const messages = await this.wcdb4Client.getMessagesAsync(
username,
startTime,
endTime,
options
)
return messages.map((message) => ({ ...message, ...message.raw }))
}
public searchAllMessages(keyword: string): string | null {
const lowerKeyword = keyword.trim().toLowerCase()
if (!lowerKeyword) return null
+77
View File
@@ -0,0 +1,77 @@
import fs from 'fs-extra'
import path from 'path'
// 仅支持 WeChat 4.0:只扫描 xwechat_files;旧 V3 时代的 "WeChat Files" 不再纳入候选
const DB_ROOT_NAMES = new Set(['xwechat_files'])
const SKIPPED_DIRECTORY_NAMES = new Set([
'$recycle.bin',
'system volume information',
'windows',
'program files',
'program files (x86)',
'programdata',
'recovery'
])
const MAX_VISITED_DIRECTORIES_PER_DRIVE = 20_000
let cachedDiscoveredRoots: string[] | null = null
function unique(values: string[]): string[] {
return Array.from(new Set(values.map((value) => path.normalize(value))))
}
export function getWindowsDrives(): string[] {
if (process.platform !== 'win32') return []
const drives: string[] = []
for (let code = 67; code <= 90; code += 1) {
const root = `${String.fromCharCode(code)}:\\`
if (fs.existsSync(root)) drives.push(root)
}
return drives
}
export function scanWindowsDbRoots(driveRoots: string[], maxDepth = 3): string[] {
const results: string[] = []
for (const driveRoot of driveRoots) {
const queue: Array<{ directory: string; depth: number }> = [{ directory: driveRoot, depth: 0 }]
let visited = 0
while (queue.length > 0 && visited < MAX_VISITED_DIRECTORIES_PER_DRIVE) {
const current = queue.shift()
if (!current || current.depth >= maxDepth) continue
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(current.directory, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
const lowered = entry.name.toLowerCase()
const fullPath = path.join(current.directory, entry.name)
const depth = current.depth + 1
visited += 1
if (DB_ROOT_NAMES.has(lowered)) {
results.push(fullPath)
continue
}
if (depth < maxDepth && !SKIPPED_DIRECTORY_NAMES.has(lowered)) {
queue.push({ directory: fullPath, depth })
}
if (visited >= MAX_VISITED_DIRECTORIES_PER_DRIVE) break
}
}
}
return unique(results)
}
export function discoverWindowsDbRoots(): string[] {
if (!cachedDiscoveredRoots) {
cachedDiscoveredRoots = scanWindowsDbRoots(getWindowsDrives(), 3)
}
return [...cachedDiscoveredRoots]
}
+98 -3
View File
@@ -38,15 +38,46 @@ import type {
ImageInsight
} from '../shared/image-insight'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update'
import type { CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
export type ParsedContent =
| { 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: 'share'
title: string
des?: string
url: string
appname?: string
typeVal?: string
}
| {
type: 'miniProgram'
title: string
description?: string
appName?: string
iconUrl?: string
thumbMd5?: string
thumbDatName?: string
thumbDataUrl?: string
}
| { type: 'redPacket'; title: string; description?: string; url?: string }
| { type: 'voip'; duration?: number; status: string; roomType?: number }
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
| {
type: 'video'
md5?: string
newMd5?: string
rawMd5?: string
duration?: number
width?: number
height?: number
}
| {
type: 'sticker'
md5?: string
@@ -71,6 +102,16 @@ declare global {
interface Window {
electron: ElectronAPI
api: {
writeAppLog: (entry: AppLogEntry) => Promise<void>
getAppLogPath: () => Promise<string>
revealAppLog: () => Promise<void>
getAppUpdateState: () => Promise<AppUpdateState>
checkAppUpdate: () => Promise<AppUpdateCheckResult>
downloadAppUpdate: () => Promise<AppUpdateCheckResult>
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
getCacheSummary: () => Promise<CacheSummary>
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
initDb: (
key: string
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
@@ -79,6 +120,11 @@ declare global {
contacts: Contact[]
updatedAt: number
} | null>
getStartupCache: () => Promise<{
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
contacts: Contact[]
updatedAt: number
} | null>
getContacts: (filter?: string) => Promise<Contact[]>
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
getCachedMessages: (
@@ -86,6 +132,26 @@ declare global {
startTime?: number,
endTime?: number
) => Promise<Message[]>
getCachedMessagePage: (
userMd5: string,
startTime?: number,
endTime?: number
) => Promise<{
hit: boolean
messages: Message[]
groupSnapshot?: {
roomId: string
memberCount: number
members: {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}[]
}
}>
getMessages: (
userMd5: string,
startTime?: number,
@@ -139,7 +205,7 @@ declare global {
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { force?: boolean }
options?: { force?: boolean; preferThumbnail?: boolean }
) => Promise<{
success: boolean
data?: string
@@ -147,10 +213,17 @@ declare global {
isThumb?: boolean
filePath?: string
}>
getVideo: (
hashes: string[]
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
getSticker: (
cdnUrl?: string,
md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }>
startExport: (request: ExportRequest) => Promise<ExportResult>
cancelExport: (jobId: string) => Promise<{ success: boolean }>
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
listGeneratedReports: () => Promise<ReportHistoryResult>
saveGeneratedReport: (
@@ -187,8 +260,13 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
appearanceTheme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
imageXorKey: string
imageAesKey: string
}
@@ -213,8 +291,13 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
appearanceTheme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
imageXorKey: string
imageAesKey: string
}
@@ -227,8 +310,13 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
appearanceTheme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
imageXorKey: string
imageAesKey: string
}>
@@ -239,8 +327,13 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
appearanceTheme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
imageXorKey: string
imageAesKey: string
}
@@ -261,7 +354,9 @@ declare global {
}>
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
disconnectDb: () => Promise<{ success: boolean; error?: string }>
disconnectDb: (options?: {
closeNative?: boolean
}) => Promise<{ success: boolean; error?: string }>
apiStatus: () => Promise<{
running: boolean
host: string
+39 -3
View File
@@ -16,15 +16,38 @@ import type {
ImageInsight
} from '../shared/image-insight'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateState } from '../shared/app-update'
import type { CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress } from '../shared/export'
// 渲染器的自定义 API
const api = {
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
getAppUpdateState: (): Promise<AppUpdateState> => ipcRenderer.invoke('app-update:getState'),
checkAppUpdate: () => ipcRenderer.invoke('app-update:check'),
downloadAppUpdate: () => ipcRenderer.invoke('app-update:download'),
installAppUpdate: () => ipcRenderer.invoke('app-update:install'),
onAppUpdateState: (callback: (state: AppUpdateState) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: AppUpdateState): void =>
callback(state)
ipcRenderer.on('app-update:state', listener)
return () => ipcRenderer.removeListener('app-update:state', listener)
},
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
ipcRenderer.invoke('cache:clear', scope),
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('db:getCachedMessagePage', userMd5, startTime, endTime),
getMessages: (
userMd5: string,
startTime?: number,
@@ -53,9 +76,19 @@ const api = {
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { force?: boolean }
options?: { force?: boolean; preferThumbnail?: boolean }
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
revealExport: (path: string) => ipcRenderer.invoke('export:reveal', path),
onExportProgress: (callback: (progress: ExportJobProgress) => void) => {
const listener = (_event: Electron.IpcRendererEvent, progress: ExportJobProgress): void =>
callback(progress)
ipcRenderer.on('export:progress', listener)
return () => ipcRenderer.removeListener('export:progress', listener)
},
exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request),
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
@@ -106,7 +139,8 @@ const api = {
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
disconnectDb: (options?: { closeNative?: boolean }) =>
ipcRenderer.invoke('db:disconnect', options),
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
apiStop: () => ipcRenderer.invoke('api:stop'),
@@ -120,7 +154,9 @@ const api = {
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
// ============================================================
imageListCandidates: (query: ImageCandidateQuery): Promise<{
imageListCandidates: (
query: ImageCandidateQuery
): Promise<{
success: boolean
candidates: ImageCandidate[]
error?: string
+1 -1
View File
@@ -7,7 +7,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' blob: data: wxe-media:;"
/>
</head>
+537 -240
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+52 -118
View File
@@ -4,7 +4,6 @@ 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 {
@@ -12,51 +11,14 @@ interface ChatWindowProps {
messages: Message[]
isLoadingMessages?: boolean
contentFilter?: string
dateRange?: string
onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onReloadAvatars?: () => Promise<void>
onLoadOlderMessages?: () => Promise<void>
onCreateGroupReport?: () => void
isAiLoading?: boolean
}
const MAX_RENDERED_MESSAGES = 600
const DATE_RANGE_LABELS: Record<string, string> = {
today: '今天',
yesterday: '昨日',
'7': '7 天',
'30': '30 天',
all: '全部'
}
const formatClock = (date: Date): string =>
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
const formatRangeDate = (date: Date, now: Date): string => {
const clock = formatClock(date)
if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}${date.getDate()}${clock}`
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${clock}`
}
const getChatHeaderRangeLabel = (range: string): string => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
if (range === 'today') return `今天 00:00—现在`
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
if (range === '7') {
const start = new Date(Date.now() - 7 * 86400000)
return `${formatRangeDate(start, now)}—现在`
}
if (range === '30') {
const start = new Date(Date.now() - 30 * 86400000)
return `${formatRangeDate(start, now)}—现在`
}
if (range === 'all') return '全部记录'
return DATE_RANGE_LABELS[range] || '当前范围'
jumpToTime?: number | null
}
const ChatWindow: React.FC<ChatWindowProps> = ({
@@ -64,12 +26,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
messages,
isLoadingMessages,
contentFilter,
dateRange = 'today',
onContentFilterChange,
onRefresh,
onRefreshData,
onReloadAvatars,
onLoadOlderMessages,
onCreateGroupReport,
isAiLoading = false
isAiLoading = false,
jumpToTime
}) => {
const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
@@ -86,6 +50,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
)
const [showAvatar, setShowAvatar] = useState(true)
const [isAtLatest, setIsAtLatest] = useState(true)
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
const previousScrollTopRef = useRef(0)
const scrollToBottom = useCallback((): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
@@ -95,13 +61,37 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
const target = event.currentTarget
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
setIsAtLatest(distanceToBottom <= 24)
if (distanceToBottom <= 24) {
setIsAtLatest(true)
} else if (target.scrollTop < previousScrollTopRef.current - 1) {
setIsAtLatest(false)
}
previousScrollTopRef.current = target.scrollTop
}, [])
useEffect(() => {
previousScrollTopRef.current = 0
setIsAtLatest(true)
}, [contact?.md5])
useEffect(() => {
if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false)
}, [jumpToTime])
useEffect(() => {
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame)
}, [messages, scrollToBottom])
}, [isAtLatest, jumpToTime, messages, scrollToBottom])
useEffect(() => {
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
const content = messageListRef.current?.querySelector('.virtual-message-list')
if (!content) return
const observer = new ResizeObserver(() => scrollToBottom())
observer.observe(content)
return () => observer.disconnect()
}, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom])
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
@@ -154,6 +144,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
imageDragRef.current = null
}
const handleReloadAvatars = async (): Promise<void> => {
if (!onReloadAvatars || isReloadingAvatars) return
setIsReloadingAvatars(true)
try {
await onReloadAvatars()
} finally {
setIsReloadingAvatars(false)
}
}
useEffect(() => {
if (!previewImage) return
@@ -172,65 +172,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}
}, [previewImage])
const handleExport = (days: ExportRange): void => {
if (!messages.length) return
let filtered = messages
if (days !== 'all') {
const now = new Date()
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
filtered = messages.filter((m) => {
const parsed = new Date(m.datetime).getTime()
if (isNaN(parsed)) return true
if (days === 0) {
// 今天
return parsed >= startOfDay
} else if (days === 1) {
// 昨天
const startOfYesterday = startOfDay - 86400000
return parsed >= startOfYesterday && parsed < startOfDay
} else if (days === 7) {
// 过去 7 天
const startOf7DaysAgo = startOfDay - 7 * 86400000
return parsed >= startOf7DaysAgo
} else if (days === 30) {
// 过去 30 天
const startOf30DaysAgo = startOfDay - 30 * 86400000
return parsed >= startOf30DaysAgo
}
return true
})
}
const headers = ['发送者', '类型', '时间', '内容']
const csvContent = [
headers.join(','),
...filtered.map((m) => {
let prefix = ''
if (isGroupChat) {
prefix = m.name ? `${m.name}: ` : ''
} else {
const name = m.from === 'user' ? contact?.m_nsNickName || '未知' : '我'
prefix = `${name}: `
}
const fullContent = `${prefix}${m.content}`
const content = fullContent.replace(/"/g, '""').replace(/\n/g, ' ')
return `"${m.from}","${m.type}","${m.datetime}","${content}"`
})
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.setAttribute('download', `${contact?.m_nsNickName || 'chat'}_export.csv`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
@@ -242,51 +183,44 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
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]
)
if (!contact) return <EmptyConversationState />
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
return (
<div className="chat-window">
<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}
messages={filteredMessages}
hiddenMessageCount={0}
isLoadingMessages={isLoadingMessages}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
listRef={messageListRef}
bottomRef={messagesEndRef}
onScroll={handleMessageListScroll}
onReachTop={onLoadOlderMessages}
onImageClick={openImagePreview}
jumpToTime={jumpToTime}
/>
<ChatStatusBar
count={renderedMessages.length}
count={filteredMessages.length}
showAvatar={showAvatar}
isAtLatest={isAtLatest}
isReloadingAvatars={isReloadingAvatars}
onShowAvatarChange={setShowAvatar}
onReloadAvatars={() => void handleReloadAvatars()}
onJumpToLatest={scrollToBottom}
/>
@@ -0,0 +1,300 @@
import React from 'react'
export type DatabaseConnectionMode = 'automatic' | 'manual'
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
interface DatabaseConnectionPageProps {
platform: string
mode: DatabaseConnectionMode
dbKey: string
dbRoot: string
showDbKey: boolean
isFetching: boolean
status: string
statusKind: DatabaseConnectionStatusKind
showMacKeyFaq: boolean
macKeyFaqUrl: string
onModeChange: (mode: DatabaseConnectionMode) => void
onDbKeyChange: (value: string) => void
onDbRootChange: (value: string) => void
onToggleDbKey: () => void
onAutoGetKey: () => void
onManualConnect: () => void
onPasteKey: () => void
onClearKey: () => void
}
function LineIcon({
name
}: {
name: 'shield' | 'lock' | 'cloud' | 'info' | 'database'
}): React.ReactElement {
const paths = {
shield: <path d="M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z" />,
lock: <path d="M7 10V8a5 5 0 0 1 10 0v2m-11 0h12v10H6V10Z" />,
cloud: (
<path d="m4 4 16 16M7.5 16H6a4 4 0 0 1-.5-8A6.5 6.5 0 0 1 17 6.8M18.5 10A4 4 0 0 1 18 18h-7" />
),
info: <path d="M12 8h.01M11 12h1v4h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" />,
database: (
<path d="M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3Zm0 0v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6m-14 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" />
)
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<g
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
{paths[name]}
</g>
</svg>
)
}
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" />
<circle cx="12" cy="12" r="3" />
{!visible && <path d="M4 4l16 16" />}
</svg>
)
}
function StoragePathHelp(): React.ReactElement {
return (
<span className="database-login-path-help">
<span
className="database-login-path-help-icon"
tabIndex={0}
aria-describedby="storage-path-help"
>
!
</span>
<span id="storage-path-help" className="database-login-path-tooltip" role="tooltip">
</span>
</span>
)
}
export function DatabaseConnectionPage({
platform,
mode,
dbKey,
dbRoot,
showDbKey,
isFetching,
status,
statusKind,
showMacKeyFaq,
macKeyFaqUrl,
onModeChange,
onDbKeyChange,
onDbRootChange,
onToggleDbKey,
onAutoGetKey,
onManualConnect,
onPasteKey,
onClearKey
}: DatabaseConnectionPageProps): React.ReactElement {
const isMac = platform === 'darwin'
const defaultPath = isMac
? '~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/'
: 'C:\\Users\\...\\WeChat Files\\Msg'
const keyIsValid = /^[0-9a-f]{64}$/i.test(dbKey.trim().replace(/^0x/i, ''))
return (
<main className="database-login-page">
<section className="database-login-brand" aria-label="WechatExplorer 产品说明">
<div className="database-login-brand-content">
<div className="database-login-logo" aria-hidden="true">
<LineIcon name="database" />
</div>
<h1>WechatExplorer</h1>
<p className="database-login-tagline"></p>
<p className="database-login-description">
</p>
<div className="database-login-promises">
<div>
<LineIcon name="shield" />
<span></span>
</div>
<div>
<LineIcon name="lock" />
<span></span>
</div>
<div>
<LineIcon name="cloud" />
<span></span>
</div>
</div>
</div>
<div className="database-login-brand-footer">LOCAL-FIRST · PRIVATE · SECURE</div>
</section>
<section className="database-login-workspace" aria-label="数据库连接">
<div className="database-login-panel">
<div className="database-login-tabs" role="tablist" aria-label="连接方式">
<button
type="button"
role="tab"
aria-selected={mode === 'automatic'}
className={mode === 'automatic' ? 'active' : ''}
onClick={() => onModeChange('automatic')}
>
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'manual'}
className={mode === 'manual' ? 'active' : ''}
onClick={() => onModeChange('manual')}
>
</button>
</div>
{mode === 'automatic' ? (
<div className="database-login-auto" role="tabpanel">
<div className={`database-login-state-card ${statusKind}`}>
<div className="database-login-state-heading">
<span className="database-login-state-icon">
<LineIcon name="info" />
</span>
<div>
<strong>
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
</strong>
<p>
{statusKind === 'error'
? status
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
</p>
</div>
</div>
<dl className="database-login-diagnostics">
<div>
<dt></dt>
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
</div>
<div>
<dt>
<StoragePathHelp />
</dt>
<dd>
<span className="database-login-path-input-wrap">
<input
type="text"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
aria-label="微信数据存储路径"
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<span className="database-login-path-value" role="status">
{dbRoot || defaultPath}
</span>
</span>
</dd>
</div>
<div>
<dt></dt>
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
</div>
</dl>
</div>
<button
type="button"
className="database-login-primary"
onClick={onAutoGetKey}
disabled={isFetching}
>
{isFetching
? '正在获取密钥…'
: statusKind === 'error'
? '重新检测'
: '自动获取密钥'}
</button>
{showMacKeyFaq && (
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
·
</a>
)}
</div>
) : (
<div className="database-login-manual" role="tabpanel">
<div className="database-login-field">
<label htmlFor="database-login-key"></label>
<div className="database-login-key-input">
<input
id="database-login-key"
type={showDbKey ? 'text' : 'password'}
value={dbKey}
onChange={(event) => onDbKeyChange(event.target.value)}
placeholder="输入或粘贴 64 位数据库密钥"
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={onToggleDbKey}
title={showDbKey ? '隐藏密钥' : '显示密钥'}
>
<EyeIcon visible={showDbKey} />
</button>
</div>
<small></small>
</div>
{platform === 'win32' && (
<div className="database-login-field">
<label htmlFor="database-login-root">
<StoragePathHelp />
</label>
<input
id="database-login-root"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
</div>
)}
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
<button
type="button"
className="database-login-primary"
onClick={onManualConnect}
disabled={!keyIsValid}
>
</button>
<button type="button" className="database-login-secondary" onClick={onPasteKey}>
</button>
</div>
)}
<div className="database-login-footer-actions">
<button type="button" onClick={onClearKey}>
</button>
<span>WechatExplorer</span>
</div>
</div>
</section>
</main>
)
}
+74 -17
View File
@@ -1,11 +1,13 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react'
import { getCachedLoadedImage, requestImage } from './image-loader'
interface ImageBubbleProps {
imageMd5?: string
imageDatName?: string
sessionId?: string
isThumb?: boolean
fallbackUrl?: string
onImageClick?: (imageUrl: string) => void
}
@@ -13,39 +15,91 @@ export function ImageBubble({
imageMd5,
imageDatName,
sessionId,
isThumb = false,
fallbackUrl,
onImageClick
}: ImageBubbleProps): JSX.Element {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const initialCachedImage = getCachedLoadedImage(imageMd5, imageDatName, {
preferThumbnail: true
})
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
const [loading, setLoading] = useState(false)
const [upgrading, setUpgrading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isThumbnail, setIsThumbnail] = useState(false)
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
const [usingFallback, setUsingFallback] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const mountedRef = useRef(true)
const backgroundUpgradeRef = useRef(false)
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
const upgradeOriginalInBackground = useCallback(() => {
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
backgroundUpgradeRef.current = true
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
.then((original) => {
if (!mountedRef.current) return
setImageUrl(original.data)
setIsThumbnail(false)
})
.catch(() => undefined)
}, [imageDatName, imageMd5, isThumbnail, sessionId])
const loadImage = useCallback(async () => {
if (imageUrl || loading) return
if (!imageMd5 && !imageDatName) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
return
}
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))
const result = await requestImage(
imageMd5,
imageDatName,
sessionId,
{ preferThumbnail: true },
0
)
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
if (result.isThumbnail) upgradeOriginalInBackground()
} catch (error) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(result.error || '加载图片失败')
setError(error instanceof Error ? error.message : '加载图片失败')
}
} catch {
setError('加载图片失败')
} finally {
setLoading(false)
}
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
}, [
fallbackUrl,
imageDatName,
imageMd5,
imageUrl,
loading,
sessionId,
upgradeOriginalInBackground
])
useEffect(() => {
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
useEffect(() => {
if (imageUrl || loading || error) return
@@ -88,12 +142,11 @@ export function ImageBubble({
setUpgrading(true)
try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
force: true
})
if (result.success && result.data?.startsWith('data:image/')) {
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
if (result.data.startsWith('data:image/')) {
setImageUrl(result.data)
setIsThumbnail(Boolean(result.isThumb))
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
onImageClick?.(result.data)
return
@@ -136,7 +189,11 @@ export function ImageBubble({
return (
<div className="image-bubble image-loaded" onClick={handleClick}>
<img src={imageUrl} alt="图片" className="image-content" />
<img
src={imageUrl}
alt="图片"
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
/>
{(upgrading || isThumbnail) && (
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
)}
@@ -2,14 +2,21 @@ import { ParsedContent } from '../../../shared/types'
import { useEffect, useState } from 'react'
import type { JSX, MouseEvent } from 'react'
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
import { ImageBubble } from './ImageBubble'
const stickerDataUrlCache = new Map<string, string>()
interface RichMessageBubbleProps {
contentData: ParsedContent
sessionId?: string
onImageClick?: (imageUrl: string) => void
}
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
export function RichMessageBubble({
contentData,
sessionId,
onImageClick
}: RichMessageBubbleProps): JSX.Element {
switch (contentData.type) {
case 'location':
return <LocationBubble data={contentData} />
@@ -17,12 +24,22 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
return <CardBubble data={contentData} />
case 'share':
return <ShareBubble data={contentData} />
case 'miniProgram':
return (
<MiniProgramBubble
data={contentData}
sessionId={sessionId}
onImageClick={onImageClick}
/>
)
case 'redPacket':
return <RedPacketBubble data={contentData} />
case 'voip':
return <VoipBubble data={contentData} />
case 'sticker':
return <StickerBubble data={contentData} />
case 'quote':
return <QuoteBubble data={contentData} />
return <QuoteBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
case 'system':
return <SystemBubble data={contentData} />
case 'unknown':
@@ -110,6 +127,83 @@ function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }>
)
}
function MiniProgramBubble({
data,
sessionId,
onImageClick
}: {
data: Extract<ParsedContent, { type: 'miniProgram' }>
sessionId?: string
onImageClick?: (imageUrl: string) => void
}): JSX.Element {
return (
<div className="mini-program-message">
<div className="mini-program-title">{data.title}</div>
{data.description && <div className="mini-program-description">{data.description}</div>}
{data.thumbDataUrl ? (
<div className="mini-program-preview">
<img
className="mini-program-inline-image"
src={data.thumbDataUrl}
alt={data.title}
onClick={() => onImageClick?.(data.thumbDataUrl || '')}
/>
</div>
) : data.thumbMd5 ? (
<div className="mini-program-preview">
<ImageBubble
imageMd5={data.thumbMd5}
imageDatName={data.thumbDatName}
sessionId={sessionId}
isThumb
fallbackUrl={data.iconUrl}
onImageClick={onImageClick}
/>
</div>
) : data.iconUrl ? (
<img
className="mini-program-icon"
src={data.iconUrl}
alt=""
referrerPolicy="no-referrer"
/>
) : null}
<div className="mini-program-footer">
<span aria-hidden></span>
<span>{data.appName || '小程序'}</span>
</div>
</div>
)
}
function RedPacketBubble({
data
}: {
data: Extract<ParsedContent, { type: 'redPacket' }>
}): JSX.Element {
const handleClick = (): void => {
if (data.url) window.open(data.url, '_blank')
}
return (
<div
className={`red-packet-message ${data.url ? 'is-clickable' : ''}`}
onClick={data.url ? handleClick : undefined}
>
<div className="red-packet-main">
<span className="red-packet-icon" aria-hidden>
¥
</span>
<span className="red-packet-copy">
<strong>{data.title}</strong>
<small>{data.description || '恭喜发财,大吉大利'}</small>
</span>
</div>
<div className="red-packet-footer"></div>
</div>
)
}
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
const { status, roomType, duration } = data
const isVideo = roomType === 1
@@ -201,7 +295,15 @@ function StickerBubble({
)
}
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
function QuoteBubble({
data,
sessionId,
onImageClick
}: {
data: Extract<ParsedContent, { type: 'quote' }>
sessionId?: string
onImageClick?: (imageUrl: string) => void
}): JSX.Element {
const quotedText = data.quotedContent || data.content || '[引用消息]'
const replyText = data.content || data.title || ''
const quotedSender = data.quotedSender || data.sender || ''
@@ -210,7 +312,17 @@ function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }>
<div className="quote-message">
<div className="quoted-message">
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
{data.quotedImageMd5 || data.quotedImageDatName ? (
<ImageBubble
imageMd5={data.quotedImageMd5}
imageDatName={data.quotedImageDatName}
sessionId={sessionId}
isThumb
onImageClick={onImageClick}
/>
) : (
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
)}
</div>
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
</div>
@@ -0,0 +1,53 @@
import { useEffect, useMemo, useState } from 'react'
interface VideoBubbleProps {
md5?: string
newMd5?: string
rawMd5?: string
duration?: number
}
export function VideoBubble({
md5,
newMd5,
rawMd5,
duration
}: VideoBubbleProps): React.ReactElement {
const hashes = useMemo(
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
[md5, newMd5, rawMd5]
)
const [media, setMedia] = useState<{ url?: string; poster?: string; error?: string }>({})
useEffect(() => {
let cancelled = false
window.api
.getVideo(hashes)
.then((result) => {
if (!cancelled) setMedia(result.success ? result : { error: result.error })
})
.catch((error) => {
if (!cancelled) setMedia({ error: error instanceof Error ? error.message : String(error) })
})
return () => {
cancelled = true
}
}, [hashes])
if (!media.url) {
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
}
return (
<div className="video-message">
<video
className="video-content"
src={media.url}
poster={media.poster}
controls
preload="metadata"
/>
{duration ? <span className="video-duration">{duration} </span> : null}
</div>
)
}
@@ -1,38 +1,31 @@
import React, { useEffect, useRef, useState } from 'react'
import { 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))
@@ -70,7 +63,6 @@ export function ChatHeader({
<h2>{displayName}</h2>
<div className="chat-title-meta">
<span>{typeLabel}</span>
<span>{dateRangeLabel}</span>
<span>{visibleCount} </span>
</div>
</div>
@@ -96,7 +88,6 @@ export function ChatHeader({
<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"
@@ -1,10 +1,13 @@
import React from 'react'
import { RefreshIcon } from './icons'
interface ChatStatusBarProps {
count: number
showAvatar: boolean
isAtLatest: boolean
isReloadingAvatars: boolean
onShowAvatarChange: (show: boolean) => void
onReloadAvatars: () => void
onJumpToLatest: () => void
}
@@ -12,7 +15,9 @@ export function ChatStatusBar({
count,
showAvatar,
isAtLatest,
isReloadingAvatars,
onShowAvatarChange,
onReloadAvatars,
onJumpToLatest
}: ChatStatusBarProps): React.ReactElement {
const jumpDisabled = isAtLatest || count === 0
@@ -29,6 +34,22 @@ export function ChatStatusBar({
/>
<span></span>
</label>
<span
className="chat-avatar-note"
title="头像或名称缺失时,请先在微信中进入该聊天,再尝试重新加载"
>
</span>
<button
type="button"
className="chat-avatar-reload"
onClick={onReloadAvatars}
disabled={isReloadingAvatars}
title="尝试重新加载当前聊天的头像和名称"
>
<RefreshIcon className={isReloadingAvatars ? 'is-spinning' : ''} />
<span>{isReloadingAvatars ? '加载中' : '重新加载头像'}</span>
</button>
<span className="chat-status-separator" aria-hidden />
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../../shared/types'
import { ImageBubble } from '../ImageBubble'
import { RichMessageBubble } from '../RichMessageBubble'
import { VoicePlayer } from '../VoicePlayer'
import { VideoBubble } from '../VideoBubble'
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
import { formatMessageTime } from './messageGrouping'
@@ -13,9 +14,25 @@ interface MessageBubbleProps {
isMine: boolean
showAvatarSpace: boolean
onImageClick: (imageUrl: string) => void
isJumpTarget?: boolean
}
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息']
const RICH_MESSAGE_TYPES = [
'名片',
'位置',
'分享消息',
'小程序',
'微信红包',
'公众号链接',
'文件',
'文件发送中',
'视频号',
'转账',
'引用消息',
'通话',
'表情包',
'系统消息'
]
export function MessageBubble({
message,
@@ -23,19 +40,23 @@ export function MessageBubble({
isGroupChat,
isMine,
showAvatarSpace,
onImageClick
onImageClick,
isJumpTarget
}: MessageBubbleProps): React.ReactElement {
const isVoice = message.type === '语音'
const isImage = message.type === '图片'
const isVideo = message.type === '视频'
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
const hoverTime = formatMessageTime(message)
return (
<div className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'}`}>
<div
className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'} ${isJumpTarget ? 'archive-jump-message' : ''}`}
>
<div
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
isImage ? 'image-message-bubble' : ''
}`}
} ${isVideo ? 'video-message-bubble' : ''}`}
>
{isVoice && message.sessionId ? (
<VoicePlayer
@@ -50,12 +71,28 @@ export function MessageBubble({
sessionId={message.sessionId}
onImageClick={onImageClick}
/>
) : isVideo && message.contentData && message.contentData.type === 'video' ? (
<VideoBubble
md5={message.contentData.md5}
newMd5={message.contentData.newMd5}
rawMd5={message.contentData.rawMd5}
duration={message.contentData.duration}
/>
) : isRichMedia && message.contentData ? (
<RichMessageBubble contentData={message.contentData} />
<RichMessageBubble
contentData={message.contentData}
sessionId={message.sessionId}
onImageClick={onImageClick}
/>
) : (
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
)}
</div>
{message.recalled && (
<span className="message-recalled-status" title={message.recalledBy || undefined}>
</span>
)}
<span className="message-hover-time">{hoverTime}</span>
{!isGroupChat && !isMine && contact.m_nsNickName && (
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
@@ -9,6 +9,7 @@ interface MessageGroupProps {
isGroupChat: boolean
showAvatar: boolean
onImageClick: (imageUrl: string) => void
jumpTargetMessageId?: string
}
export function MessageGroup({
@@ -16,7 +17,8 @@ export function MessageGroup({
contact,
isGroupChat,
showAvatar,
onImageClick
onImageClick,
jumpTargetMessageId
}: MessageGroupProps): React.ReactElement {
const firstMessage = group.messages[0]
@@ -57,9 +59,7 @@ export function MessageGroup({
)}
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
<div className="message-stack">
{!isMine && isGroupChat && shouldShowAvatar && (
<div className="message-sender-name">{displayName}</div>
)}
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
{group.messages.map((message, index) => (
<MessageBubble
key={message.id}
@@ -69,6 +69,7 @@ export function MessageGroup({
isMine={isMine}
showAvatarSpace={index === 0}
onImageClick={onImageClick}
isJumpTarget={message.id === jumpTargetMessageId}
/>
))}
</div>
+112 -12
View File
@@ -1,4 +1,5 @@
import React from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Contact, Message } from '../../../../shared/types'
import { MessageGroup } from './MessageGroup'
import { buildMessageGroups } from './messageGrouping'
@@ -13,7 +14,9 @@ interface MessageListProps {
listRef: React.RefObject<HTMLDivElement | null>
bottomRef: React.RefObject<HTMLDivElement | null>
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
onReachTop?: () => Promise<void>
onImageClick: (imageUrl: string) => void
jumpToTime?: number | null
}
export function MessageList({
@@ -26,12 +29,95 @@ export function MessageList({
listRef,
bottomRef,
onScroll,
onImageClick
onReachTop,
onImageClick,
jumpToTime
}: MessageListProps): React.ReactElement {
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
const groupsRef = React.useRef(groups)
const loadingOlderRef = React.useRef(false)
groupsRef.current = groups
const virtualizer = useVirtualizer({
count: groups.length,
getScrollElement: () => listRef.current,
estimateSize: () => 96,
getItemKey: (index) => groups[index]?.id || index,
overscan: 8
})
const virtualItems = virtualizer.getVirtualItems()
const jumpTarget = React.useMemo(() => {
if (jumpToTime === undefined || jumpToTime === null) return null
const groupIndex = groups.findIndex((group) =>
group.messages.some((message) => (message.createTime || 0) >= jumpToTime)
)
if (groupIndex < 0) return null
const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime)
return { groupIndex, messageId: message?.id }
}, [groups, jumpToTime])
React.useEffect(() => {
if (!jumpTarget) return
const frame = window.requestAnimationFrame(() => {
virtualizer.scrollToIndex(jumpTarget.groupIndex, { align: 'center' })
})
return () => window.cancelAnimationFrame(frame)
}, [jumpTarget, virtualizer])
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
onScroll(event)
const scrollElement = event.currentTarget
if (
(jumpToTime !== undefined && jumpToTime !== null) ||
scrollElement.scrollTop >= 48 ||
loadingOlderRef.current ||
isLoadingMessages ||
!onReachTop
) {
return
}
loadingOlderRef.current = true
const previousGroupCount = groups.length
const previousScrollTop = scrollElement.scrollTop
const previousScrollHeight = scrollElement.scrollHeight
const anchorMessageId = groups[0]?.messages[0]?.id
void (async () => {
try {
await onReachTop()
for (let frame = 0; frame < 8; frame += 1) {
if (groupsRef.current.length > previousGroupCount) break
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
}
if (!anchorMessageId) return
const anchorIndex = groupsRef.current.findIndex((group) =>
group.messages.some((message) => message.id === anchorMessageId)
)
if (anchorIndex <= 0) return
virtualizer.measure()
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
const addedHeight = scrollElement.scrollHeight - previousScrollHeight
if (addedHeight > 0) {
scrollElement.scrollTop = previousScrollTop + addedHeight
} else {
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
}
// Variable-height groups can finish measuring one frame later. Preserve
// the same message anchor after that final measurement as well.
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
if (scrollElement.scrollTop < 48) {
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
}
} finally {
window.setTimeout(() => {
loadingOlderRef.current = false
}, 250)
}
})()
}
return (
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
{isLoadingMessages && <div className="message-loading-pill">...</div>}
{hiddenMessageCount > 0 && (
<div className="wechat-system-message-row">
@@ -40,16 +126,30 @@ export function MessageList({
</div>
</div>
)}
{groups.map((group) => (
<MessageGroup
key={group.id}
group={group}
contact={contact}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
onImageClick={onImageClick}
/>
))}
<div className="virtual-message-list" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualItems.map((virtualItem) => {
const group = groups[virtualItem.index]
if (!group) return null
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className={`virtual-message-group ${jumpTarget?.groupIndex === virtualItem.index ? 'archive-jump-target-group' : ''}`}
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<MessageGroup
group={group}
contact={contact}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
onImageClick={onImageClick}
jumpTargetMessageId={jumpTarget?.messageId}
/>
</div>
)
})}
</div>
<div ref={bottomRef} />
</div>
)
@@ -1,4 +1,5 @@
import React from 'react'
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Contact } from '../../../../shared/types'
import { ConversationItem } from './ConversationItem'
@@ -29,6 +30,31 @@ export function ConversationSection({
onToggle,
onSelectContact
}: ConversationSectionProps): React.ReactElement {
const listRef = useRef<HTMLDivElement>(null)
const [scrollMargin, setScrollMargin] = useState(0)
useLayoutEffect(() => {
const updateMargin = (): void => {
const list = listRef.current
const scrollElement = document.querySelector<HTMLDivElement>('.conversation-list')
if (!list || !scrollElement) return
setScrollMargin(list.getBoundingClientRect().top - scrollElement.getBoundingClientRect().top)
}
updateMargin()
window.addEventListener('resize', updateMargin)
return () => window.removeEventListener('resize', updateMargin)
}, [contacts.length, expanded])
const virtualizer = useVirtualizer({
count: expanded ? contacts.length : 0,
getScrollElement: () => document.querySelector<HTMLDivElement>('.conversation-list'),
scrollMargin,
estimateSize: () => 58,
overscan: 8
})
useEffect(() => {
if (expanded) virtualizer.measure()
}, [contacts.length, expanded, virtualizer])
return (
<section className="conversation-section">
<button type="button" className="conversation-section-header" onClick={onToggle}>
@@ -40,16 +66,29 @@ export function ConversationSection({
</span>
</button>
{expanded && (
<div className="conversation-section-list">
<div ref={listRef} className="conversation-section-list">
{contacts.length > 0 ? (
contacts.map((contact) => (
<ConversationItem
key={contact.md5}
contact={contact}
active={selectedContact?.md5 === contact.md5}
onSelect={onSelectContact}
/>
))
<div
className="conversation-section-virtual-content"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const contact = contacts[virtualItem.index]
return (
<div
key={virtualItem.key}
className="conversation-section-virtual-row"
style={{ transform: `translateY(${virtualItem.start - scrollMargin}px)` }}
>
<ConversationItem
contact={contact}
active={selectedContact?.md5 === contact.md5}
onSelect={onSelectContact}
/>
</div>
)
})}
</div>
) : (
<div className="conversation-section-empty">{emptyText}</div>
)}
@@ -1,7 +1,8 @@
import React, { useState } from 'react'
import React, { useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Contact } from '../../../../shared/types'
import { AccountSummary } from '../account/AccountSummary'
import { ConversationSection } from './ConversationSection'
import { ConversationItem } from './ConversationItem'
import { ConversationSidebarHeader } from './ConversationSidebarHeader'
interface SelfInfo {
@@ -18,30 +19,55 @@ export interface ConversationSidebarProps {
onSearch: (keyword: string) => void
onContentFilter: (keyword: string) => void
width: number
dateRange: string
onDateRangeChange: (range: string) => void
selfInfo: SelfInfo | null
dbReady: boolean
onOpenSettings: () => void
}
type SectionName = 'groups' | 'contacts'
type ConversationRow =
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
| { kind: 'contact'; id: string; contact: Contact }
export function ConversationSidebar({
contacts,
selectedContact,
onSelectContact,
onSearch,
width,
dateRange,
onDateRangeChange,
selfInfo,
dbReady,
onOpenSettings
}: ConversationSidebarProps): React.ReactElement {
const [searchTerm, setSearchTerm] = useState('')
const [expandedSection, setExpandedSection] = useState<'groups' | 'contacts'>('groups')
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
groups: true,
contacts: false
})
const listRef = useRef<HTMLDivElement>(null)
const groups = contacts.filter((contact) => contact.type === 'group')
const users = contacts.filter((contact) => contact.type === 'user')
const rows = useMemo<ConversationRow[]>(
() => [
{ kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' },
...(expandedSections.groups
? groups.map((contact) => ({ kind: 'contact' as const, id: `group-${contact.md5}`, contact }))
: []),
{ kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' },
...(expandedSections.contacts
? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact }))
: [])
],
[expandedSections.contacts, expandedSections.groups, groups, users]
)
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => listRef.current,
estimateSize: (index) => (rows[index]?.kind === 'header' ? 38 : 58),
getItemKey: (index) => rows[index]?.id || index,
overscan: 10
})
const handleSearchChange = (term: string): void => {
setSearchTerm(term)
@@ -53,29 +79,49 @@ export function ConversationSidebar({
<ConversationSidebarHeader
totalCount={contacts.length}
searchValue={searchTerm}
dateRange={dateRange}
onSearchChange={handleSearchChange}
onDateRangeChange={onDateRangeChange}
/>
<div className="conversation-list" aria-label="会话列表">
<ConversationSection
title="群聊"
contacts={groups}
expanded={expandedSection === 'groups'}
selectedContact={selectedContact}
emptyText="暂无群聊"
onToggle={() => setExpandedSection('groups')}
onSelectContact={onSelectContact}
/>
<ConversationSection
title="联系人"
contacts={users}
expanded={expandedSection === 'contacts'}
selectedContact={selectedContact}
emptyText="暂无联系人"
onToggle={() => setExpandedSection('contacts')}
onSelectContact={onSelectContact}
/>
<div ref={listRef} className="conversation-list" aria-label="会话列表">
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const row = rows[virtualItem.index]
if (!row) return null
if (row.kind === 'header') {
const expanded = expandedSections[row.section]
return (
<button
key={virtualItem.key}
type="button"
className="conversation-section-header conversation-virtual-row"
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
onClick={() =>
setExpandedSections((current) => ({ ...current, [row.section]: !current[row.section] }))
}
>
<span className="conversation-section-chevron" aria-hidden="true">
<svg viewBox="0 0 16 16" focusable="false">
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
</svg>
</span>
<span className="conversation-section-title">{row.title} ({row.count})</span>
</button>
)
}
return (
<div
key={virtualItem.key}
className="conversation-virtual-row"
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
>
<ConversationItem
contact={row.contact}
active={selectedContact?.md5 === row.contact.md5}
onSelect={onSelectContact}
/>
</div>
)
})}
</div>
</div>
<div className="conversation-sidebar-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
@@ -1,21 +1,16 @@
import React from 'react'
import { ConversationSearch } from './ConversationSearch'
import { DateRangeSelector } from './DateRangeSelector'
interface ConversationSidebarHeaderProps {
totalCount: number
searchValue: string
dateRange: string
onSearchChange: (value: string) => void
onDateRangeChange: (range: string) => void
}
export function ConversationSidebarHeader({
totalCount,
searchValue,
dateRange,
onSearchChange,
onDateRangeChange
onSearchChange
}: ConversationSidebarHeaderProps): React.ReactElement {
return (
<div className="conversation-sidebar-header">
@@ -24,7 +19,6 @@ export function ConversationSidebarHeader({
<span>{totalCount} </span>
</div>
<ConversationSearch value={searchValue} onChange={onSearchChange} />
<DateRangeSelector value={dateRange} onChange={onDateRangeChange} />
</div>
)
}
@@ -1,31 +0,0 @@
import React from 'react'
interface DateRangeSelectorProps {
value: string
onChange: (range: string) => void
}
const DATE_RANGE_OPTIONS = [
{ key: 'today', label: '今天' },
{ key: 'yesterday', label: '昨日' },
{ key: '7', label: '7 天' },
{ key: '30', label: '30 天' },
{ key: 'all', label: '全部' }
]
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement {
return (
<div className="conversation-date-range" aria-label="时间范围">
{DATE_RANGE_OPTIONS.map((item) => (
<button
key={item.key}
type="button"
className={`conversation-date-range-button ${value === item.key ? 'active' : ''}`}
onClick={() => onChange(item.key)}
>
{item.label}
</button>
))}
</div>
)
}
@@ -0,0 +1,107 @@
import React from 'react'
import type { Contact, SelfInfo } from './exportTypes'
import { displayName } from './exportUtils'
interface ExportContactPanelProps {
contacts: Contact[]
filteredContacts: Contact[]
activeContact: Contact | null
selfInfo: SelfInfo | null
dbReady: boolean
contactFilter: string
contactType: 'all' | 'group' | 'user'
onContactFilterChange: (value: string) => void
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
onSelectContact: (contact: Contact) => void
onOpenSettings: () => void
}
export function ExportContactPanel({
contacts,
filteredContacts,
activeContact,
selfInfo,
dbReady,
contactFilter,
contactType,
onContactFilterChange,
onContactTypeChange,
onSelectContact,
onOpenSettings
}: ExportContactPanelProps): React.ReactElement {
return (
<aside className="export-contact-panel">
<div className="export-panel-header">
<div className="export-panel-title-row">
<h2></h2>
<span className="export-count-badge"> {contacts.length.toLocaleString()} </span>
</div>
<label className="export-search-field">
<span aria-hidden></span>
<input
value={contactFilter}
onChange={(event) => onContactFilterChange(event.target.value)}
placeholder="搜索群聊、联系人或 wxid"
aria-label="搜索聊天"
/>
</label>
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型">
{(
[
['all', '全部'],
['group', '群聊'],
['user', '联系人']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={contactType === value ? 'active' : ''}
onClick={() => onContactTypeChange(value)}
>
{label}
</button>
))}
</div>
</div>
<div className="export-contact-list">
{filteredContacts.map((contact) => {
const name = displayName(contact)
return (
<button
key={contact.md5}
type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
>
<span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
</span>
<span className="export-contact-copy">
<strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
</button>
)
})}
</div>
<button type="button" className="export-account-summary" onClick={onOpenSettings}>
<span className="export-account-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt="" />
) : (
(selfInfo?.nickname || '我').slice(0, 1)
)}
</span>
<span>
<strong>{selfInfo?.nickname || '当前账号'}</strong>
<small className={dbReady ? 'ready' : ''}>
{dbReady ? '数据库已连接' : '数据库未连接'}
</small>
</span>
</button>
</aside>
)
}
@@ -0,0 +1,164 @@
import React from 'react'
import type { Message } from './exportTypes'
import type { ExportJobProgress, ExportStatus, SelfInfo } from './exportTypes'
import { formatPreviewTime } from './exportUtils'
interface ExportPreviewPanelProps {
status: ExportStatus
previewItems: Message[]
previewMediaCount: number
previewBytes: number
selfInfo: SelfInfo | null
progress: ExportJobProgress | null
jobId: string
onCancel: (jobId: string) => void
onReveal: (path: string) => void
}
export function ExportPreviewPanel({
status,
previewItems,
previewMediaCount,
previewBytes,
selfInfo,
progress,
jobId,
onCancel,
onReveal
}: ExportPreviewPanelProps): React.ReactElement {
return (
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
{status === 'idle' && (
<>
<div className="export-preview-heading">
<strong></strong>
<span> 20 </span>
</div>
<div className="export-message-preview">
<div className="export-preview-date"></div>
{(previewItems.length
? previewItems
: [
{
id: 'empty',
from: 'user',
content: '导出预览将在这里显示',
type: '文字',
datetime: '',
isSender: false
}
]
).map((message) => (
<div
key={message.id}
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
}`}
>
<span className="export-preview-avatar">
{message.img || (message.isSender && selfInfo?.avatar) ? (
<img src={message.img || selfInfo?.avatar} alt="" />
) : (
(message.isSender ? '我' : message.name || '友').slice(0, 1)
)}
</span>
<span className="export-preview-bubble">
<small>
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
{formatPreviewTime(message)}
</small>
{message.content || `[${message.type}]`}
</span>
</div>
))}
</div>
<div className="export-preview-stats export-preview-real-stats">
<span>
<strong>{previewItems.length}</strong>
</span>
<span>
<strong>{previewMediaCount}</strong>
</span>
<span>
<strong>
{previewBytes < 1024
? `${previewBytes} B`
: `${(previewBytes / 1024).toFixed(1)} KB`}
</strong>
</span>
</div>
<div className="export-preview-stats">
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
</>
)}
{status === 'running' && (
<div className="export-job-state">
<h2></h2>
<p></p>
<ol>
<li className="done"></li>
<li className="current">
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
</li>
<li></li>
<li></li>
<li></li>
</ol>
<div className="export-progress-bar" aria-label="导出进度">
<span style={{ width: `${progress?.percent ?? 0}%` }} />
</div>
<strong>
{progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong>
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
</button>
</div>
)}
{status === 'completed' && (
<div className="export-job-state completed">
<div className="export-success-icon"></div>
<h2></h2>
<p></p>
<div className="export-complete-summary">
<span>
<strong>{progress?.processed.toLocaleString() || '已完成'}</strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
<button
type="button"
className="export-primary-button"
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
>
</button>
<button
type="button"
className="export-open-folder-button"
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
>
</button>
</div>
)}
</aside>
)
}
@@ -0,0 +1,57 @@
import React from 'react'
import type { ExportTaskRecord } from './exportTypes'
interface ExportTaskCenterProps {
open: boolean
taskCount: number
tasks: ExportTaskRecord[]
onToggle: () => void
onCancel: (jobId: string) => void
}
export function ExportTaskCenter({
open,
taskCount,
tasks,
onToggle,
onCancel
}: ExportTaskCenterProps): React.ReactElement {
return (
<>
<button type="button" className="export-task-center-button" onClick={onToggle}>
{taskCount > 0 ? ` (${taskCount})` : ''}
</button>
{open && (
<section className="export-task-center">
<div className="export-section-heading">
<h3></h3>
<span>{tasks.length} </span>
</div>
{tasks.length === 0 ? (
<p></p>
) : (
tasks.map((task) => (
<div className="export-task-row" key={task.jobId}>
<span>
<strong>{task.contactName}</strong>
<small>
{task.format.toUpperCase()} · {task.progress.phase}
</small>
</span>
<span className="export-task-progress">
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
<b>{task.progress.percent ?? 0}%</b>
</span>
{task.status === 'running' && (
<button type="button" onClick={() => onCancel(task.jobId)}>
</button>
)}
</div>
))
)}
</section>
)}
</>
)
}
@@ -0,0 +1,575 @@
import React, { useMemo, useState } from 'react'
import type { Message } from '../../../../shared/types'
import type {
ExportJobProgress,
ExportMessageKind,
ExportNameMode
} from '../../../../shared/export'
import { ExportContactPanel } from './ExportContactPanel'
import { ExportPreviewPanel } from './ExportPreviewPanel'
import { ExportTaskCenter } from './ExportTaskCenter'
import type {
ExportFormat,
ExportRange,
ExportStatus,
ExportWorkspaceProps,
GroupMemberName
} from './exportTypes'
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
export function ExportWorkspace({
contacts,
selectedContact,
previewMessages,
selfInfo,
dbReady,
onSelectContact,
onOpenSettings,
exportTasks,
onStartExport,
onCancelExport
}: ExportWorkspaceProps): React.ReactElement {
const [contactFilter, setContactFilter] = useState('')
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
const [range, setRange] = useState<ExportRange>('today')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
const [includeMedia, setIncludeMedia] = useState(true)
const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(false)
const [format, setFormat] = useState<ExportFormat>('csv')
const [zip, setZip] = useState(false)
const [fileName, setFileName] = useState('')
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
const filteredContacts = useMemo(() => {
const keyword = contactFilter.trim().toLowerCase()
return contacts.filter((contact) => {
if (contactType !== 'all' && contact.type !== contactType) return false
if (!keyword) return true
return [contact.m_nsNickName, contact.m_nsUsrName].some((value) =>
value.toLowerCase().includes(keyword)
)
})
}, [contactFilter, contactType, contacts])
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5)
const taskCount = exportTasks.filter((task) => task.status === 'running').length
const activeName = displayName(activeContact)
const preview = previewMessages.slice(-20)
const previewMediaCount = preview.filter((message) =>
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '')
).length
const previewBytes = preview.reduce(
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
0
)
const outputName = fileName.trim() || `${activeName}_聊天档案`
const nameOptions: { value: ExportNameMode; label: string }[] =
activeContact?.type === 'group'
? [
{ value: 'groupNickname', label: '群昵称' },
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
: [
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
const nameMap = useMemo(() => {
const map: Record<string, string> = {}
if (activeContact?.type === 'group') {
for (const member of groupMembers) {
const value =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
map[member.wxid] = value
}
} else if (activeContact) {
map[activeContact.m_nsUsrName] =
nameMode === 'remark'
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
: activeContact.wechatNickname || activeContact.m_nsUsrName
}
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
return map
}, [activeContact, groupMembers, nameMode, selfInfo])
const avatarUrls = useMemo(() => {
const map: Record<string, string> = {}
if (activeContact?.m_nsUsrName && activeContact.avatar) {
map[activeContact.m_nsUsrName] = activeContact.avatar
}
for (const member of groupMembers) {
if (member.avatar) map[member.wxid] = member.avatar
}
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
return map
}, [activeContact, groupMembers, selfInfo])
const previewName = (message: Message): string =>
(message.senderId && nameMap[message.senderId]) ||
message.name ||
(message.isSender ? selfInfo?.nickname : undefined) ||
(message.isSender ? '我' : '联系人')
const previewAvatar = (message: Message): string | undefined =>
(message.senderId && avatarUrls[message.senderId]) ||
message.img ||
(message.isSender ? selfInfo?.avatar : undefined)
const previewItems = preview.map((message) => ({
...message,
name: previewName(message),
img: previewAvatar(message)
}))
React.useEffect(() => {
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
setGroupMembers([])
if (!activeContact || activeContact.type !== 'group') return
const timer = window.setTimeout(() => {
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
setGroupMembers((snapshot?.members || []) as GroupMemberName[])
})
}, 300)
return () => window.clearTimeout(timer)
}, [activeContact])
const toggleKind = (value: string): void => {
setSelectedKinds((current) => {
const next = new Set(current)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
})
}
const handleStart = async (): Promise<void> => {
if (!activeContact || status === 'running') return
const nextJobId = `export-${Date.now()}`
setJobId(nextJobId)
setProgress(null)
setStatus('running')
let exportNameMap = nameMap
let exportAvatarUrls = avatarUrls
if (activeContact.type === 'group') {
const snapshot = await window.api.getGroupSnapshot(activeContact.md5)
const members = (snapshot?.members || []) as GroupMemberName[]
setGroupMembers(members)
exportNameMap = { ...nameMap }
exportAvatarUrls = { ...avatarUrls }
for (const member of members) {
exportNameMap[member.wxid] =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar
}
}
const now = new Date()
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
const startOfRange = days
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
: null
const request = {
jobId: nextJobId,
userMd5: activeContact.md5,
name: activeName,
format,
outputName,
startTime: startOfRange
? Math.floor(startOfRange.getTime() / 1000)
: range === 'custom' && startDate
? Math.floor(new Date(startDate).getTime() / 1000)
: undefined,
endTime: startOfRange
? Math.floor(endOfToday.getTime() / 1000)
: range === 'custom' && endDate
? Math.floor(new Date(endDate).getTime() / 1000)
: undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia,
includeAvatars,
avatarUrls: exportAvatarUrls,
nameMode,
nameMap: exportNameMap,
zip
}
const result = await onStartExport(request)
if (result.success) {
setProgress((current) => ({
...(current || { jobId: nextJobId, processed: result.messageCount || 0 }),
phase: 'completed',
processed: result.messageCount ?? current?.processed ?? 0,
total: result.messageCount ?? current?.total,
percent: 100,
outputPath: result.outputPath
}))
setStatus('completed')
} else if (result.error !== '已取消') {
setStatus('idle')
}
}
React.useEffect(
() =>
window.api.onExportProgress((next) => {
if (next.jobId !== jobId) return
setProgress(next)
if (next.phase === 'completed') setStatus('completed')
if (next.phase === 'cancelled' || next.phase === 'failed') setStatus('idle')
}),
[jobId]
)
React.useEffect(() => {
if (!currentTask) return
setJobId(currentTask.jobId)
setProgress(currentTask.progress)
setStatus(
currentTask.status === 'running'
? 'running'
: currentTask.status === 'completed'
? 'completed'
: 'idle'
)
}, [currentTask])
const targetPath =
format === 'html'
? zip
? `文稿/WechatExplorer/导出/${outputName}.zip`
: `文稿/WechatExplorer/导出/${outputName}/`
: `文稿/WechatExplorer/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
return (
<div className="export-workspace">
<ExportContactPanel
contacts={contacts}
filteredContacts={filteredContacts}
activeContact={activeContact}
selfInfo={selfInfo}
dbReady={dbReady}
contactFilter={contactFilter}
contactType={contactType}
onContactFilterChange={setContactFilter}
onContactTypeChange={setContactType}
onSelectContact={onSelectContact}
onOpenSettings={onOpenSettings}
/>
<main className="export-config-panel">
<div className="export-config-scroll">
<ExportTaskCenter
open={taskCenterOpen}
taskCount={taskCount}
tasks={exportTasks}
onToggle={() => setTaskCenterOpen((open) => !open)}
onCancel={(taskJobId) => void onCancelExport(taskJobId)}
/>
<header className="export-config-header">
<span className="export-chat-avatar">
{activeContact?.avatar ? (
<img src={activeContact.avatar} alt="" />
) : (
activeName.slice(0, 1)
)}
</span>
<span>
<h1></h1>
<p>
{activeName}
{activeContact?.type === 'group' ? ' · 群聊' : ''}
</p>
</span>
</header>
<section className="export-section export-format-top">
<h3></h3>
<div className="export-format-grid">
{formatOrder.map((value) => (
<button key={value} type="button" className={format === value ? 'active' : ''} onClick={() => setFormat(value)}>
<strong>{formatLabels[value].label}</strong>
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
</button>
))}
</div>
<p className="export-helper-text">CSV HTML </p>
{format === 'html' && (
<div className="export-html-options">
<label>
<input type="radio" name="html-package-top" checked={!zip} onChange={() => setZip(false)} /> HTML
</label>
<label>
<input type="radio" name="html-package-top" checked={zip} onChange={() => setZip(true)} /> HTML ZIP
</label>
</div>
)}
</section>
<section className="export-section">
<div className="export-section-heading">
<h3></h3>
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
</div>
<div className="export-range-toggle">
<button
type="button"
className={range === 'today' ? 'active' : ''}
onClick={() => setRange('today')}
>
</button>
<button
type="button"
className={range === 'threeDays' ? 'active' : ''}
onClick={() => setRange('threeDays')}
>
3
</button>
<button
type="button"
className={range === 'sevenDays' ? 'active' : ''}
onClick={() => setRange('sevenDays')}
>
7
</button>
<button
type="button"
className={range === 'custom' ? 'active' : ''}
onClick={() => setRange('custom')}
>
</button>
</div>
{range === 'custom' && (
<div className="export-date-fields">
<label>
<input
type="datetime-local"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<label>
<input
type="datetime-local"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
</label>
</div>
)}
</section>
<section className="export-section">
<h3></h3>
<div className="export-kind-grid">
{messageKinds.map(([value, label]) => (
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}>
<input
type="checkbox"
checked={value !== 'video' && selectedKinds.has(value)}
disabled={value === 'video'}
onChange={() => toggleKind(value)}
/>
<span>{label}</span>
{value === 'video' && (
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
!
</span>
)}
</label>
))}
</div>
</section>
<section className="export-section">
<h3></h3>
<div className="export-name-mode-grid" role="radiogroup" aria-label="消息显示名称">
{nameOptions.map((option) => (
<label key={option.value} className="export-name-mode-option">
<input
type="radio"
name="export-name-mode"
checked={nameMode === option.value}
onChange={() => setNameMode(option.value)}
/>
<span>{option.label}</span>
</label>
))}
</div>
</section>
<section className="export-section">
<h3></h3>
<label className="export-media-master">
<span></span>
<input
type="checkbox"
checked={includeMedia}
disabled={format !== 'html'}
onChange={(event) => setIncludeMedia(event.target.checked)}
/>
</label>
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}>
<label className="export-check-row">
<input
type="checkbox"
checked={preferOriginal}
disabled={!includeMedia || format !== 'html'}
onChange={(event) => setPreferOriginal(event.target.checked)}
/>
<span></span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={fallbackThumbnail}
disabled={!includeMedia || format !== 'html'}
onChange={(event) => setFallbackThumbnail(event.target.checked)}
/>
<span>使</span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={keepMissing}
disabled={!includeMedia || format !== 'html'}
onChange={(event) => setKeepMissing(event.target.checked)}
/>
<span></span>
</label>
</div>
<p className="export-helper-text"> HTML CSVJSON Markdown </p>
<div className="export-resource-statuses">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<p className="export-helper-text"></p>
<label className="export-media-master">
<span></span>
<input
type="checkbox"
checked={includeAvatars}
onChange={(event) => setIncludeAvatars(event.target.checked)}
/>
</label>
</section>
<section className="export-section">
<h3></h3>
<div className="export-format-grid">
{formatOrder.map((value) => (
<button
key={value}
type="button"
className={format === value ? 'active' : ''}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
</button>
))}
</div>
{format === 'html' && (
<div className="export-html-options">
<label>
<input
type="radio"
name="html-package"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label>
<label>
<input
type="radio"
name="html-package"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label>
</div>
)}
</section>
<section className="export-section export-save-section">
<h3></h3>
<label>
<input
value={fileName}
onChange={(event) => setFileName(event.target.value)}
placeholder={`${activeName}_聊天档案`}
/>
</label>
<div className="export-target-path">
<span></span>
<strong>{targetPath}</strong>
<button type="button"></button>
</div>
</section>
</div>
<footer className="export-action-bar">
<span className={`export-ready-dot ${status === 'completed' ? 'completed' : ''}`} />
<span>
{status === 'running'
? '正在后台导出'
: status === 'completed'
? '导出完成'
: '准备就绪'}
</span>
<span className="export-target-summary">{targetPath}</span>
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
</button>
<button
type="button"
className="export-primary-button"
disabled={!activeContact || status === 'running'}
onClick={handleStart}
>
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
</button>
</footer>
</main>
<ExportPreviewPanel
status={status}
previewItems={previewItems}
previewMediaCount={previewMediaCount}
previewBytes={previewBytes}
selfInfo={selfInfo}
progress={progress}
jobId={jobId}
onCancel={(exportJobId) => {
void window.api.cancelExport(exportJobId)
setStatus('idle')
}}
onReveal={(path) => void window.api.revealExport(path)}
/>
</div>
)
}
@@ -0,0 +1,44 @@
import type { Contact, Message } from '../../../../shared/types'
import type {
ExportJobProgress,
ExportMessageKind,
ExportNameMode,
ExportRequest,
ExportResult,
ExportTaskRecord
} from '../../../../shared/export'
export type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
export type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
export type ExportStatus = 'idle' | 'running' | 'completed'
export interface GroupMemberName {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
export interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
export interface ExportWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
previewMessages: Message[]
selfInfo: SelfInfo | null
dbReady: boolean
onSelectContact: (contact: Contact) => void
onOpenSettings: () => void
exportTasks: ExportTaskRecord[]
onStartExport: (request: ExportRequest) => Promise<ExportResult>
onCancelExport: (jobId: string) => Promise<void>
}
export type { Contact, ExportJobProgress, ExportMessageKind, Message, ExportNameMode, ExportTaskRecord }
@@ -0,0 +1,77 @@
import type { Contact, Message } from '../../../../shared/types'
import type { ExportFormat, GroupMemberName } from './exportTypes'
export const messageKinds = [
['text', '文字'],
['image', '图片'],
['video', '视频'],
['voice', '语音'],
['sticker', '表情包'],
['share', '链接与分享'],
['location', '位置'],
['system', '系统消息']
] as const
export const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
html: { label: 'HTML', hint: '推荐' },
csv: { label: 'CSV' },
json: { label: 'JSON' },
markdown: { label: 'Markdown' }
}
export const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
export function displayName(contact: Contact | null): string {
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
}
export function formatPreviewTime(message: Message): string {
if (!message.createTime) return message.datetime || ''
return new Date(message.createTime * 1000).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
export function buildNameMap(
activeContact: Contact | null,
groupMembers: GroupMemberName[],
nameMode: string,
selfInfo: { wxid: string; nickname: string } | null
): Record<string, string> {
const map: Record<string, string> = {}
if (activeContact?.type === 'group') {
for (const member of groupMembers) {
const value =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
map[member.wxid] = value
}
} else if (activeContact) {
map[activeContact.m_nsUsrName] =
nameMode === 'remark'
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
: activeContact.wechatNickname || activeContact.m_nsUsrName
}
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
return map
}
export function buildAvatarUrls(
activeContact: Contact | null,
groupMembers: GroupMemberName[],
selfInfo: { wxid: string; avatar?: string } | null
): Record<string, string> {
const map: Record<string, string> = {}
if (activeContact?.m_nsUsrName && activeContact.avatar) {
map[activeContact.m_nsUsrName] = activeContact.avatar
}
for (const member of groupMembers) {
if (member.avatar) map[member.wxid] = member.avatar
}
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
return map
}
+167
View File
@@ -0,0 +1,167 @@
export type LoadedImage = {
data: string
isThumbnail: boolean
}
export type ImageLoadOptions = {
force?: boolean
preferThumbnail?: boolean
}
type QueueItem = {
priority: number
run: () => Promise<LoadedImage>
resolve: (value: LoadedImage) => void
reject: (error: Error) => void
}
const MAX_CONCURRENT_IMAGE_LOADS = 3
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
const imageCache = new Map<string, LoadedImage>()
const imageCacheSizes = new Map<string, number>()
const imageRequests = new Map<string, Promise<LoadedImage>>()
const imageQueue: QueueItem[] = []
let activeImageLoads = 0
let imageCacheBytes = 0
function imageIdentityKeys(imageMd5?: string, imageDatName?: string): string[] {
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
Boolean
)
}
function cacheMode(options: ImageLoadOptions): string {
if (options.force) return 'original'
if (options.preferThumbnail) return 'thumbnail'
return 'auto'
}
function cacheKeys(
imageMd5: string | undefined,
imageDatName: string | undefined,
options: ImageLoadOptions
): string[] {
return imageIdentityKeys(imageMd5, imageDatName).map(
(identity) => `${identity}:${cacheMode(options)}`
)
}
function getCachedImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
options: ImageLoadOptions
): LoadedImage | undefined {
const keys = cacheKeys(imageMd5, imageDatName, options)
for (const key of keys) {
const cached = imageCache.get(key)
if (!cached) continue
imageCache.delete(key)
imageCache.set(key, cached)
return cached
}
if (!options.force && options.preferThumbnail) {
for (const identity of imageIdentityKeys(imageMd5, imageDatName)) {
const fallbackKey = `${identity}:auto`
const cached = imageCache.get(fallbackKey)
if (cached) return cached
}
}
return undefined
}
function cacheImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
options: ImageLoadOptions,
image: LoadedImage
): void {
const keys = cacheKeys(imageMd5, imageDatName, options)
const size = image.data.length * 2
for (const key of keys) {
const previousSize = imageCacheSizes.get(key) || 0
imageCacheBytes -= previousSize
imageCache.delete(key)
imageCacheSizes.delete(key)
imageCache.set(key, image)
imageCacheSizes.set(key, size)
imageCacheBytes += size
}
while (imageCacheBytes > MAX_IMAGE_CACHE_BYTES && imageCache.size > 1) {
const oldestKey = imageCache.keys().next().value
if (!oldestKey) break
imageCache.delete(oldestKey)
imageCacheBytes -= imageCacheSizes.get(oldestKey) || 0
imageCacheSizes.delete(oldestKey)
}
}
function pumpImageQueue(): void {
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
imageQueue.sort((left, right) => left.priority - right.priority)
const item = imageQueue.shift()
if (!item) return
activeImageLoads += 1
void item
.run()
.then(item.resolve, item.reject)
.finally(() => {
activeImageLoads -= 1
pumpImageQueue()
})
}
}
export function getCachedLoadedImage(
imageMd5?: string,
imageDatName?: string,
options: ImageLoadOptions = {}
): LoadedImage | undefined {
return getCachedImage(imageMd5, imageDatName, options)
}
export function requestImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
sessionId: string | undefined,
options: ImageLoadOptions = {},
priority = 0
): Promise<LoadedImage> {
const cached = getCachedImage(imageMd5, imageDatName, options)
if (cached) return Promise.resolve(cached)
const identity = imageIdentityKeys(imageMd5, imageDatName)[0]
if (!identity) return Promise.reject(new Error('缺少图片标识'))
const requestKey = `${identity}:${cacheMode(options)}`
const existingRequest = imageRequests.get(requestKey)
if (existingRequest) return existingRequest
const request = new Promise<LoadedImage>((resolve, reject) => {
imageQueue.push({
priority,
resolve,
reject,
run: async () => {
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
if (!result.success || !result.data?.startsWith('data:image/')) {
throw new Error(result.error || '加载图片失败')
}
const loadedImage = {
data: result.data,
isThumbnail: Boolean(result.isThumb)
}
cacheImage(imageMd5, imageDatName, options, loadedImage)
return loadedImage
}
})
pumpImageQueue()
})
imageRequests.set(requestKey, request)
void request.then(
() => imageRequests.delete(requestKey),
() => imageRequests.delete(requestKey)
)
return request
}
@@ -17,6 +17,8 @@ interface AppShellProps {
dbReady: boolean
onPageChange: (page: AppPage) => void
onOpenSettings: () => void
appearanceTheme?: 'system' | 'light' | 'dark'
compactMode?: boolean
children: React.ReactNode
}
@@ -34,12 +36,14 @@ export function AppShell({
dbReady,
onPageChange,
onOpenSettings,
appearanceTheme = 'system',
compactMode = false,
children
}: AppShellProps): React.ReactElement {
const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage)
return (
<div className="app-shell">
<div className={`app-shell theme-${appearanceTheme} ${compactMode ? 'is-compact' : ''}`}>
<aside className="app-primary-rail">
<BrandLogo />
<PrimaryNavigation activePage={activePage} onPageChange={onPageChange} />
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
interface ReportTaskStatusPanelProps {
@@ -27,6 +27,14 @@ export function ReportTaskStatusPanel({
}: ReportTaskStatusPanelProps): React.ReactElement {
const activeIndex = phaseIndex(phase)
const completedAll = phase === 'success'
const [logPath, setLogPath] = useState('')
useEffect(() => {
void window.api
.getAppLogPath()
.then(setLogPath)
.catch(() => undefined)
}, [])
return (
<aside className="report-task-panel">
@@ -70,6 +78,10 @@ export function ReportTaskStatusPanel({
<button type="button" onClick={onRetry}>
</button>
<button type="button" onClick={() => void window.api.revealAppLog()}>
</button>
{logPath && <small className="report-task-log-path">{logPath}</small>}
</div>
)}
{phase === 'success' && (
@@ -37,10 +37,7 @@ export function ReportViewer({
return () => window.cancelAnimationFrame(frame)
}, [report?.id])
const title = useMemo(
() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'),
[report]
)
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
const fitWidth = (): void => {
const viewport = viewportRef.current
@@ -108,10 +105,16 @@ export function ReportViewer({
}}
onLoad={(event) => {
const image = event.currentTarget
setNaturalSize({
const nextSize = {
width: image.naturalWidth,
height: image.naturalHeight
})
}
setNaturalSize(nextSize)
const viewport = viewportRef.current
if (viewport && nextSize.width > viewport.clientWidth - 48) {
const fittedZoom = Math.max(0.25, (viewport.clientWidth - 48) / nextSize.width)
setZoom(Number(fittedZoom.toFixed(2)))
}
}}
onError={() => {
setImageError('日报图片加载失败')
@@ -0,0 +1,891 @@
import React, { useMemo, useRef, useState } from 'react'
import type { Contact, Message } from '../../../../shared/types'
import { SearchIcon } from '../chat/icons'
import type {
AISearchCacheRecord,
AISearchWorkspaceProps,
EvidenceItem,
GroupMemberName,
SearchPassSummary,
SearchRange,
SearchScope,
SearchStage,
SenderDirectory
} from './searchTypes'
import {
RANGE_LABELS,
SEARCH_CACHE_KEY,
SEARCH_HISTORY_KEY,
buildLocalSearchPlan,
buildSearchCacheKey,
compactCacheItem,
currentTimestamp,
evidenceIdentity,
formatMemberName,
formatMessageDate,
formatMessageTime,
getRangeStart,
includesSearchAlias,
messageDateKey,
messageIdentity,
messageText,
mergeSearchPlans,
normalizeSearchText,
parseSearchCacheKey,
parseSearchPlanResponse,
readSearchCache,
readSearchCacheByQuery,
selectEvenly,
selectEvidenceByDate,
senderName,
writeSearchCache
} from './searchUtils'
import { renderMarkdown } from './searchMarkdown'
export function AISearchWorkspace({
contacts,
selectedContact,
dbReady,
aiModelConfig,
onSelectContact,
onOpenEvidence,
onOpenAISettings,
onNotice
}: AISearchWorkspaceProps): React.ReactElement {
const allContacts = useMemo(() => contacts.filter((contact) => contact.md5), [contacts])
const availableContacts = allContacts.slice(0, 80)
const [scope, setScope] = useState<SearchScope>('global')
const [scopeContactMd5, setScopeContactMd5] = useState(selectedContact?.md5 || '')
const [range, setRange] = useState<SearchRange>('7d')
const [contactFilter, setContactFilter] = useState('')
const [query, setQuery] = useState('')
const [stage, setStage] = useState<SearchStage>('idle')
const [answer, setAnswer] = useState('')
const [evidence, setEvidence] = useState<EvidenceItem[]>([])
const [selectedEvidence, setSelectedEvidence] = useState(0)
const [analysisError, setAnalysisError] = useState('')
const [messageCount, setMessageCount] = useState(0)
const [history, setHistory] = useState<string[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]')
return Array.isArray(stored)
? stored.filter((item): item is string => typeof item === 'string')
: []
} catch {
return []
}
})
const [senderNames, setSenderNames] = useState<Record<string, string>>({})
const [cachedAt, setCachedAt] = useState(0)
const [debugEnabled, setDebugEnabled] = useState(false)
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
const [debugEntries, setDebugEntries] = useState<string[]>([])
const [appLogPath, setAppLogPath] = useState('')
const bypassCacheRef = useRef(false)
React.useEffect(() => {
void Promise.all([window.api.getSettings(), window.api.getAppLogPath()]).then(
([settingsResult, logPath]) => {
setDebugEnabled(settingsResult.settings.debugEnabled)
setAppLogPath(logPath)
}
)
}, [])
const addDebugEntry = (message: string, details: Record<string, unknown> = {}): void => {
const entry = `${new Date().toLocaleTimeString('zh-CN')} ${message} ${JSON.stringify(details)}`
setDebugEntries((current) => [entry, ...current].slice(0, 80))
if (debugEnabled) {
void window.api
.writeAppLog({ level: 'info', scope: 'ai-search', message, details })
.catch(() => undefined)
}
}
const activeContact =
availableContacts.find(
(contact) => contact.md5 === (scopeContactMd5 || selectedContact?.md5)
) || selectedContact
const visibleContacts = availableContacts.filter((contact) => {
const keyword = contactFilter.trim().toLowerCase()
if (!keyword) return true
return (
contact.m_nsNickName.toLowerCase().includes(keyword) ||
contact.m_nsUsrName.toLowerCase().includes(keyword)
)
})
const sourceLabel =
scope === 'conversation' ? activeContact?.m_nsNickName || '未选择会话' : '全局搜索'
const modelLabel = aiModelConfig.configured
? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}`
: '尚未配置 AI 模型'
const buildSourceContacts = (): Contact[] => {
if (scope === 'conversation') return activeContact ? [activeContact] : []
return allContacts
}
const loadSourcePages = async (
sourceContacts: Contact[],
startTime: number | undefined
): Promise<{ contact: Contact; messages: Message[] }[]> => {
const pages = new Array<{ contact: Contact; messages: Message[] }>(sourceContacts.length)
let nextIndex = 0
const worker = async (): Promise<void> => {
while (nextIndex < sourceContacts.length) {
const index = nextIndex
nextIndex += 1
const contact = sourceContacts[index]
pages[index] = {
contact,
messages: await window.api.getMessages(contact.md5, startTime)
}
}
}
const workerCount = Math.min(6, sourceContacts.length)
await Promise.all(Array.from({ length: workerCount }, () => worker()))
return pages
}
const rememberQuery = (value: string): void => {
setHistory((current) => {
const next = [value, ...current.filter((item) => item !== value)].slice(0, 10)
try {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next))
} catch {
// History persistence is optional and must not interrupt analysis.
}
return next
})
}
const removeHistoryQuery = (historyQuery: string): void => {
setHistory((current) => {
const next = current.filter((item) => item !== historyQuery)
try {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next))
} catch {
// History persistence is optional and must not interrupt analysis.
}
return next
})
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const queryKey = historyQuery.trim().toLowerCase()
const nextRecords = records.filter((item) => {
try {
const keyParts = JSON.parse(item.key) as unknown
return !(
Array.isArray(keyParts) &&
typeof keyParts[3] === 'string' &&
keyParts[3] === queryKey
)
} catch {
return true
}
})
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords))
} catch {
// Cache cleanup is optional and must not interrupt the current workspace.
}
onNotice('已删除这条最近提问')
}
const applyCachedResult = (cached: AISearchCacheRecord, queryValue = query.trim()): void => {
setAnswer(cached.answer)
setEvidence(cached.evidence)
setSenderNames(cached.senderNames)
setMessageCount(cached.messageCount)
setCachedAt(cached.createdAt)
rememberQuery(queryValue)
}
const restoreHistoryQuery = (historyQuery: string): void => {
setQuery(historyQuery)
setSelectedEvidence(0)
const cacheKey = buildSearchCacheKey(
scope,
scope === 'conversation' ? activeContact?.md5 || '' : '',
range,
historyQuery
)
const cached = readSearchCache(cacheKey) || readSearchCacheByQuery(historyQuery)?.record || null
if (!cached) {
setAnswer('')
setEvidence([])
setCachedAt(0)
setStage('idle')
onNotice('这条提问没有可恢复的缓存,请点击开始分析重新读取消息')
return
}
const cachedLocation = parseSearchCacheKey(cached.key)
if (cachedLocation) {
setScope(cachedLocation.scope)
setRange(cachedLocation.range)
setScopeContactMd5(cachedLocation.contactMd5)
}
setAnalysisError('')
applyCachedResult(cached, historyQuery)
setStage('result')
onNotice('已恢复这条提问的检索结果')
}
const loadSenderDirectory = async (sourceContacts: Contact[]): Promise<SenderDirectory> => {
const groupContacts = sourceContacts.filter((contact) => contact.type === 'group')
const snapshots = await Promise.all(
groupContacts.map(async (contact) => {
const snapshot = (await window.api.getGroupSnapshot(contact.md5)) as {
members?: GroupMemberName[]
} | null
return snapshot?.members || []
})
)
const displayNames: Record<string, string> = {}
const aliases: Record<string, string[]> = {}
snapshots.flat().forEach((member) => {
if (!member.wxid) return
displayNames[member.wxid] = formatMemberName(member)
aliases[member.wxid] = [
member.groupNickname,
member.nickname,
member.remark,
member.wechatNickname,
member.wxid
].filter((name): name is string => Boolean(name?.trim()))
})
return { displayNames, aliases }
}
const runAnalysis = async (event?: React.FormEvent): Promise<void> => {
event?.preventDefault()
const normalizedQuery = query.trim()
if (!normalizedQuery) {
setAnalysisError('先输入一个想了解的问题')
setStage('insufficient')
return
}
if (!dbReady) {
setAnalysisError('数据库尚未连接,暂时无法读取聊天记录')
setStage('insufficient')
return
}
if (!aiModelConfig.configured) {
setAnalysisError('尚未配置 AI 模型,请先在设置中添加可用的模型供应商')
setStage('insufficient')
return
}
const sourceContacts = buildSourceContacts()
if (!sourceContacts.length) {
setAnalysisError('没有可检索的聊天范围')
setStage('insufficient')
return
}
setStage('loading')
setAnalysisError('')
setAnswer('')
setEvidence([])
setSelectedEvidence(0)
setCachedAt(0)
const cacheKey = buildSearchCacheKey(
scope,
scope === 'conversation' ? activeContact?.md5 || '' : '',
range,
normalizedQuery
)
try {
const cached = bypassCacheRef.current ? null : readSearchCache(cacheKey)
bypassCacheRef.current = false
if (cached) {
addDebugEntry('检索命中缓存', { scope, range, messageCount: cached.messageCount })
applyCachedResult(cached, normalizedQuery)
setStage('result')
onNotice('已使用最近的检索缓存,可点击刷新数据读取最新消息')
return
}
const localSearchPlan = buildLocalSearchPlan(normalizedQuery)
let searchPlan = localSearchPlan
try {
const planResult = await window.api.aiChat([
{
role: 'system',
content:
'你是本地聊天检索规划器,不回答用户问题。请从用户问题中提取用于本地数据库检索的主题词和同义短语,只输出 JSON{"intent":"general|topic|participants|mixed","keywords":["..."],"variants":["..."]}。删除“全局搜索、我和谁聊过、这个话题”等意图词,不要编造人名或聊天内容。'
},
{
role: 'user',
content: `用户问题:${normalizedQuery}`
}
])
const aiPlan =
planResult.success && planResult.data ? parseSearchPlanResponse(planResult.data) : null
searchPlan = mergeSearchPlans(localSearchPlan, aiPlan)
addDebugEntry('AI 检索规划完成', {
source: searchPlan.source,
intent: searchPlan.intent,
keywords: searchPlan.keywords,
variants: searchPlan.variants,
aiPlanSuccess: Boolean(aiPlan)
})
} catch (error) {
addDebugEntry('AI 检索规划失败,使用本地规划', {
error: error instanceof Error ? error.message : '未知错误',
keywords: localSearchPlan.keywords
})
}
const startTime = getRangeStart(range)
const pages = await loadSourcePages(sourceContacts, startTime)
const sourceMessages = pages.flatMap((page) =>
page.messages.map((message) => ({ contact: page.contact, message }))
)
const uniqueMessages = Array.from(
new Map(
sourceMessages.map((item) => [
`${item.contact.md5}:${messageIdentity(item.message)}`,
item
])
).values()
).sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0))
if (!uniqueMessages.length) {
addDebugEntry('检索没有消息', { contactCount: sourceContacts.length, scope, range })
setAnalysisError('当前范围内没有找到可分析的消息,请扩大时间范围或更换会话')
setStage('insufficient')
return
}
const queryKeywords = searchPlan.keywords
const fuzzySearchKeywords = searchPlan.variants
const contactNamesInQuery = sourceContacts.filter((contact) =>
[contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname]
.filter((name): name is string => Boolean(name?.trim()))
.some((name) => includesSearchAlias(normalizedQuery, name))
)
const senderDirectoryContacts =
scope === 'conversation' ? sourceContacts : contactNamesInQuery
const querySenderDirectory = await loadSenderDirectory(senderDirectoryContacts)
const matchedSenderIds = new Set(
Object.entries(querySenderDirectory.aliases)
.filter(([, aliases]) =>
aliases.some((name) => includesSearchAlias(normalizedQuery, name))
)
.map(([senderId]) => senderId)
)
const matchedContactIds = new Set(contactNamesInQuery.map((contact) => contact.md5))
const senderMatchedMessages = uniqueMessages.filter(({ message }) => {
const senderFields = [message.name, message.senderId, message.from].filter(
(value): value is string => Boolean(value?.trim())
)
return (
senderFields.some((value) => matchedSenderIds.has(value)) ||
senderFields.some((value) => includesSearchAlias(normalizedQuery, value))
)
})
const senderMessageIds = new Set(senderMatchedMessages.map(evidenceIdentity))
const searchPasses = [
{ label: '主题精确匹配', keywords: queryKeywords },
{ label: 'AI 变体匹配', keywords: fuzzySearchKeywords }
]
const keywordMatchedMap = new Map<string, EvidenceItem>()
const passSummaries: SearchPassSummary[] = []
for (const pass of searchPasses) {
const passMatches = uniqueMessages.filter(({ message }) => {
const text = normalizeSearchText(messageText(message))
return pass.keywords.some((keyword) => text.includes(normalizeSearchText(keyword)))
})
passMatches.forEach((item) => keywordMatchedMap.set(evidenceIdentity(item), item))
passSummaries.push({
label: pass.label,
keywords: pass.keywords,
messageCount: passMatches.length
})
}
const keywordMatchedMessages = Array.from(keywordMatchedMap.values())
const keywordMessageIds = new Set(keywordMatchedMessages.map(evidenceIdentity))
const relevantMessages = uniqueMessages.filter(({ contact, message }) => {
if (matchedContactIds.has(contact.md5)) return true
const itemKey = evidenceIdentity({ contact, message })
return senderMessageIds.has(itemKey) || keywordMessageIds.has(itemKey)
})
const hasSearchConstraint =
queryKeywords.length > 0 || fuzzySearchKeywords.length > 0 || matchedContactIds.size > 0
if (!relevantMessages.length && hasSearchConstraint) {
const attemptedTerms = Array.from(new Set([...queryKeywords, ...fuzzySearchKeywords])).join(
'、'
)
const noResultMessage = `${RANGE_LABELS[range]}内没有找到包含“${attemptedTerms || '主题关键词'}”的聊天消息。已完成精确匹配和智能变体匹配,未回退到全量消息;可以扩大时间范围或换一个更具体的词。`
addDebugEntry('检索未找到相关消息', {
contactCount: sourceContacts.length,
uniqueMessageCount: uniqueMessages.length,
passSummaries,
fallbackToAllMessages: false
})
setAnalysisError(noResultMessage)
setStage('insufficient')
return
}
const analysisMessages = relevantMessages.length ? relevantMessages : uniqueMessages
addDebugEntry('检索消息匹配完成', {
contactCount: sourceContacts.length,
uniqueMessageCount: uniqueMessages.length,
contactNameMatchCount: contactNamesInQuery.length,
searchIntent: searchPlan.intent,
searchPlanSource: searchPlan.source,
queryKeywords,
fuzzyKeywordCount: fuzzySearchKeywords.length,
passSummaries,
queryAliasCount: Object.keys(querySenderDirectory.aliases).length,
senderMatchCount: matchedSenderIds.size,
senderMessageCount: senderMatchedMessages.length,
keywordMessageCount: keywordMatchedMessages.length,
relevantMessageCount: relevantMessages.length,
fallbackToAllMessages: relevantMessages.length === 0
})
const analysisContactIds = new Set(analysisMessages.map(({ contact }) => contact.md5))
const analysisContacts = sourceContacts.filter((contact) =>
analysisContactIds.has(contact.md5)
)
const resolvedSenderNames = {
...querySenderDirectory.displayNames,
...(
await loadSenderDirectory(
analysisContacts.filter((contact) => !matchedContactIds.has(contact.md5))
)
).displayNames
}
const primaryMessages = senderMatchedMessages.length
? senderMatchedMessages
: keywordMatchedMessages
const primaryMessageIds = new Set(primaryMessages.map(evidenceIdentity))
const selectedEvidenceItems = primaryMessages.length
? selectEvenly(primaryMessages, 8)
: selectEvidenceByDate(analysisMessages, 8)
const contextItems = primaryMessages.length
? [
...selectEvenly(primaryMessages, Math.min(160, primaryMessages.length)),
...selectEvidenceByDate(
analysisMessages.filter((item) => !primaryMessageIds.has(evidenceIdentity(item))),
80
)
]
: selectEvidenceByDate(analysisMessages, 240)
const dateCounts = new Map<string, number>()
const senderCounts = new Map<string, number>()
analysisMessages.forEach(({ contact, message }) => {
const date = messageDateKey(message)
dateCounts.set(date, (dateCounts.get(date) || 0) + 1)
const name = senderName(message, contact, resolvedSenderNames)
senderCounts.set(name, (senderCounts.get(name) || 0) + 1)
})
const dateSummary = Array.from(dateCounts.entries())
.map(([date, count]) => `${formatMessageDate(date)} ${count}`)
.join('、')
const senderSummary = Array.from(senderCounts.entries())
.sort(([, left], [, right]) => right - left)
.slice(0, 12)
.map(([name, count]) => `${name} ${count}`)
.join('、')
const asksConversationParticipants =
searchPlan.intent === 'participants' || searchPlan.intent === 'mixed'
const context = contextItems
.map(
({ contact, message }) =>
`[${formatMessageTime(message)}] ${contact.m_nsNickName} / ${senderName(message, contact, resolvedSenderNames)}: ${messageText(message)}`
)
.join('\n')
const aiResult = await window.api.aiChat([
{
role: 'system',
content:
'你是 WechatExplorer 的本地聊天记录分析助手。只能基于提供的消息回答,不得编造事实。请用中文回答,先给出简短摘要,再列出关键主题、结论和不确定性。对人物问题只能描述群聊中的发言主题和可能角色,不做人格或敏感属性判断。'
},
{
role: 'user',
content: `检索范围:${sourceLabel},时间:${RANGE_LABELS[range]}\n用户问题:${normalizedQuery}\n检索意图:${searchPlan.intent}\n检索关键词:${queryKeywords.join('、') || '未提取到主题关键词'}\n检索变体:${fuzzySearchKeywords.join('、') || '无'}\n相关消息数:${analysisMessages.length}\n目标成员消息数:${senderMatchedMessages.length}\n检索范围消息总数:${uniqueMessages.length}\n覆盖日期:${new Set(analysisMessages.map((item) => messageDateKey(item.message))).size}\n按日期统计:${dateSummary}\n主要发言者统计:${senderSummary}\n${asksConversationParticipants ? '\n这是一个“我和谁聊过”的问题,请按聊天会话和联系人归纳,优先列出实际出现主题关键词的会话,不要根据全量消息猜测。' : ''}\n以下是按检索轮次命中的原始消息,优先级最高的消息排在前面,不代表全部消息:\n${context}`
}
])
if (!aiResult.success || !aiResult.data) {
setAnalysisError(aiResult.error || 'AI 分析失败,请稍后重试')
setStage('insufficient')
return
}
setAnswer(aiResult.data)
setEvidence(selectedEvidenceItems)
setSenderNames(resolvedSenderNames)
setMessageCount(uniqueMessages.length)
rememberQuery(normalizedQuery)
writeSearchCache({
version: 1,
key: cacheKey,
createdAt: currentTimestamp(),
answer: aiResult.data,
evidence: selectedEvidenceItems.map(compactCacheItem),
senderNames: resolvedSenderNames,
messageCount: uniqueMessages.length
})
setStage('result')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '读取聊天记录失败'
addDebugEntry('检索失败', { error: errorMessage })
setAnalysisError(errorMessage)
setStage('insufficient')
}
}
const copyAnswer = async (): Promise<void> => {
if (!answer) return
const result = await window.api.copyText(answer)
onNotice(result.success ? 'AI 摘要已复制' : result.error || '复制失败')
}
const renderIdle = (): React.ReactElement => (
<div className="ai-search-empty">
<div className="ai-search-empty-mark" aria-hidden>
<span></span>
</div>
<span className="ai-search-kicker">LOCAL AI WORKSPACE</span>
<h2></h2>
<p>AI </p>
<div className="ai-search-prompts">
{[
'交友群"张三"最近聊了什么?',
'工作群"李四"今天发布了什么任务?',
'我和"老李"最近聊了什么话题?',
'全局搜一下 我和谁聊过 去健身?'
].map((prompt) => (
<button key={prompt} type="button" onClick={() => setQuery(prompt)}>
{prompt}
</button>
))}
</div>
</div>
)
const renderLoading = (): React.ReactElement => (
<div className="ai-search-loading">
<div className="ai-search-spinner" aria-hidden />
<span className="ai-search-kicker">AI </span>
<h2></h2>
<p>
{sourceLabel} · {RANGE_LABELS[range]}
</p>
<div className="ai-search-loading-steps">
<span className="done"> </span>
<span className="active"> </span>
<span> </span>
</div>
</div>
)
const renderResult = (): React.ReactElement => (
<div className="ai-search-result">
<div className="ai-search-result-header">
<div>
<span className="ai-search-kicker">AI </span>
<h2>{query}</h2>
<p>
{sourceLabel} · {RANGE_LABELS[range]} · {messageCount} · {' '}
{evidence.length} {cachedAt ? ' · 已使用缓存' : ''}
</p>
</div>
<div className="ai-search-result-actions">
<button type="button" onClick={() => void copyAnswer()} title="复制 AI 摘要">
</button>
<button
type="button"
onClick={() => {
bypassCacheRef.current = true
void runAnalysis()
}}
title="跳过缓存并重新读取聊天记录"
>
</button>
</div>
</div>
<section className="ai-search-summary-block">
<div className="ai-search-section-heading">
<span />
</div>
<div className="ai-search-answer">{renderMarkdown(answer)}</div>
</section>
</div>
)
const renderInsufficient = (): React.ReactElement => (
<div className="ai-search-insufficient">
<div className="ai-search-insufficient-icon">!</div>
<span className="ai-search-kicker"></span>
<h2>{analysisError || '当前范围没有足够证据'}</h2>
<p></p>
<button
type="button"
className="primary"
onClick={() => {
setRange('30d')
setStage('idle')
}}
>
30
</button>
</div>
)
return (
<div className="ai-search-workspace">
<header className="ai-search-header">
<div>
<span className="ai-search-kicker">WechatExplorer · LOCAL INTELLIGENCE</span>
<h1>AI </h1>
<p></p>
</div>
<div className="ai-search-header-actions">
<div className="ai-search-model-status">
<span className={aiModelConfig.configured ? 'ready' : 'warning'} />
<span>{modelLabel}</span>
{!aiModelConfig.configured && (
<button type="button" onClick={onOpenAISettings}>
</button>
)}
</div>
{debugEnabled && (
<button
type="button"
className={`ai-search-debug-button ${debugPanelOpen ? 'active' : ''}`}
onClick={() => setDebugPanelOpen((open) => !open)}
title="查看本次检索诊断信息"
>
</button>
)}
</div>
</header>
{debugEnabled && debugPanelOpen && (
<section className="ai-search-debug-panel">
<div className="ai-search-debug-header">
<div>
<strong></strong>
<span>
{debugEnabled ? '已写入应用日志' : '仅显示本次会话,设置中可开启持久化日志'}
</span>
</div>
<div className="ai-search-debug-actions">
<button type="button" onClick={() => setDebugEntries([])}>
</button>
<button type="button" onClick={() => void window.api.revealAppLog()}>
</button>
</div>
</div>
{appLogPath && <small className="ai-search-debug-path">{appLogPath}</small>}
<pre>{debugEntries.length ? debugEntries.join('\n') : '等待下一次检索操作...'}</pre>
</section>
)}
<div className="ai-search-grid">
<aside className="ai-search-scope-panel">
<div className="ai-search-panel-heading">
<div>
<span></span>
<strong></strong>
</div>
<span className="ai-search-local-badge"></span>
</div>
<div className="ai-search-scope-toggle">
<button
type="button"
className={scope === 'global' ? 'active' : ''}
onClick={() => setScope('global')}
>
</button>
<button
type="button"
className={scope === 'conversation' ? 'active' : ''}
onClick={() => {
if (activeContact) {
setScope('conversation')
setScopeContactMd5(activeContact.md5)
return
}
onNotice('请先从下方选择一个会话')
}}
>
</button>
</div>
<p className="ai-search-scope-help">
{scope === 'global'
? '全局搜索会读取全部群聊和私聊;左侧列表用于快速指定会话。'
: '指定会话只读取当前高亮的一个群聊或私聊。'}
</p>
<label className="ai-search-field-label" htmlFor="ai-search-contact-filter">
</label>
<div className="ai-search-filter-input">
<SearchIcon />
<input
id="ai-search-contact-filter"
value={contactFilter}
onChange={(event) => setContactFilter(event.target.value)}
placeholder="搜索群聊或联系人"
/>
</div>
<div className="ai-search-contact-list">
{visibleContacts.map((contact) => (
<button
key={contact.md5}
type="button"
className={`ai-search-contact ${scope === 'conversation' && scopeContactMd5 === contact.md5 ? 'active' : ''}`}
onClick={() => {
setScope('conversation')
setScopeContactMd5(contact.md5)
onSelectContact(contact)
}}
>
<span className="ai-search-contact-avatar">
{contact.avatar ? (
<img src={contact.avatar} alt="" />
) : (
contact.m_nsNickName.charAt(0)
)}
</span>
<span>
<strong>{contact.m_nsNickName || contact.m_nsUsrName}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
</button>
))}
{!visibleContacts.length && <span className="ai-search-muted"></span>}
</div>
<div className="ai-search-field-heading">
<label className="ai-search-field-label"></label>
<span>{RANGE_LABELS[range]}</span>
</div>
<div className="ai-search-range-grid">
{(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => (
<button
key={item}
type="button"
className={range === item ? 'active' : ''}
aria-pressed={range === item}
onClick={() => setRange(item)}
>
{RANGE_LABELS[item]}
</button>
))}
</div>
<div className="ai-search-filter-note">
<span></span>
</div>
{history.length > 0 && (
<div className="ai-search-history">
<label className="ai-search-field-label"></label>
{history.map((item) => (
<div className="ai-search-history-item" key={item}>
<button
type="button"
onClick={() => restoreHistoryQuery(item)}
title="恢复这条提问"
>
{item}
</button>
<button
type="button"
className="ai-search-history-delete"
aria-label={`删除最近提问:${item}`}
title="删除这条提问"
onClick={() => removeHistoryQuery(item)}
>
×
</button>
</div>
))}
</div>
)}
</aside>
<main className="ai-search-main">
<div className="ai-search-main-scroll">
{stage === 'idle' && renderIdle()}
{stage === 'loading' && renderLoading()}
{stage === 'result' && renderResult()}
{stage === 'insufficient' && renderInsufficient()}
</div>
<form className="ai-search-composer" onSubmit={(event) => void runAnalysis(event)}>
<div className="ai-search-composer-meta">
<span></span>
<strong>{sourceLabel}</strong>
<em>{RANGE_LABELS[range]}</em>
</div>
<div className="ai-search-composer-row">
<textarea
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="例如:技术交流群最近讨论了哪些 Windows 性能问题?"
rows={2}
/>
<button type="submit" className="primary" disabled={stage === 'loading'}>
{stage === 'loading' ? '分析中' : '开始分析'}
<span></span>
</button>
</div>
<div className="ai-search-composer-foot">
<span>Enter · Shift + Enter </span>
<span>AI 使</span>
</div>
</form>
</main>
<aside className="ai-search-evidence-panel">
<div className="ai-search-panel-heading">
<div>
<span></span>
<strong></strong>
</div>
{evidence.length > 0 && (
<span className="ai-search-count-badge">{evidence.length} </span>
)}
</div>
<div className="ai-search-evidence-meta">
<span></span>
<strong>{sourceLabel}</strong>
<span></span>
<strong>{RANGE_LABELS[range]}</strong>
</div>
{evidence.length ? (
evidence.map((item, index) => (
<button
key={`${messageIdentity(item.message)}-${index}`}
type="button"
className={`ai-search-evidence-card ${selectedEvidence === index ? 'active' : ''}`}
onClick={() => {
setSelectedEvidence(index)
onOpenEvidence(item.contact, item.message.createTime)
}}
>
<span className="ai-search-evidence-card-top">
<strong>{senderName(item.message, item.contact, senderNames)}</strong>
<time>{formatMessageTime(item.message)}</time>
</span>
<span className="ai-search-evidence-conversation">{item.contact.m_nsNickName}</span>
<span className="ai-search-evidence-text">{messageText(item.message)}</span>
<span className="ai-search-evidence-link"> </span>
</button>
))
) : (
<div className="ai-search-evidence-empty">
<div></div>
<strong></strong>
<span></span>
</div>
)}
</aside>
</div>
</div>
)
}
@@ -0,0 +1,46 @@
import React from 'react'
const inlineMarkdown = (value: string, keyPrefix: string): React.ReactNode[] =>
value.split(/(\*\*.*?\*\*|`.*?`|\*.*?\*)/g).map((part, index) => {
const key = `${keyPrefix}-${index}`
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={key}>{part.slice(2, -2)}</strong>
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={key}>{part.slice(1, -1)}</code>
}
if (part.startsWith('*') && part.endsWith('*')) {
return <em key={key}>{part.slice(1, -1)}</em>
}
return <React.Fragment key={key}>{part}</React.Fragment>
})
export const renderMarkdown = (value: string): React.ReactNode =>
value.split(/\r?\n/).map((line, index) => {
const key = `markdown-${index}`
if (!line.trim()) return <div key={key} className="ai-search-markdown-spacer" />
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
if (heading) {
const Heading = `h${heading[1].length}` as 'h1' | 'h2' | 'h3'
return <Heading key={key}>{inlineMarkdown(heading[2], key)}</Heading>
}
const bullet = /^\s*[-*]\s+(.+)$/.exec(line)
if (bullet) {
return (
<div key={key} className="ai-search-markdown-list-item">
<span aria-hidden></span>
<span>{inlineMarkdown(bullet[1], key)}</span>
</div>
)
}
const numbered = /^\s*\d+[.)]\s+(.+)$/.exec(line)
if (numbered) {
return (
<div key={key} className="ai-search-markdown-list-item">
<span aria-hidden>{line.trim().match(/^\d+/)?.[0]}.</span>
<span>{inlineMarkdown(numbered[1], key)}</span>
</div>
)
}
return <p key={key}>{inlineMarkdown(line, key)}</p>
})
@@ -0,0 +1,59 @@
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
import type { Contact, Message } from '../../../../shared/types'
export type SearchStage = 'idle' | 'loading' | 'result' | 'insufficient'
export type SearchScope = 'global' | 'conversation'
export type SearchRange = 'today' | '7d' | '30d' | 'all'
export type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed'
export interface EvidenceItem {
contact: Contact
message: Message
}
export interface AISearchCacheRecord {
version: 1
key: string
createdAt: number
answer: string
evidence: EvidenceItem[]
senderNames: Record<string, string>
messageCount: number
}
export interface GroupMemberName {
wxid: string
nickname?: string
groupNickname?: string
remark?: string
wechatNickname?: string
}
export interface SenderDirectory {
displayNames: Record<string, string>
aliases: Record<string, string[]>
}
export interface SearchQueryPlan {
intent: SearchIntent
keywords: string[]
variants: string[]
source: 'local' | 'ai' | 'hybrid'
}
export interface SearchPassSummary {
label: string
keywords: string[]
messageCount: number
}
export interface AISearchWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
dbReady: boolean
aiModelConfig: AIRuntimeModelConfig
onSelectContact: (contact: Contact) => void
onOpenEvidence: (contact: Contact, createTime?: number) => void
onOpenAISettings: () => void
onNotice: (message: string) => void
}
@@ -0,0 +1,372 @@
import type { Contact, Message } from '../../../../shared/types'
import type { AISearchCacheRecord, EvidenceItem, GroupMemberName, SearchIntent, SearchQueryPlan, SearchRange, SearchScope } from './searchTypes'
export const RANGE_LABELS: Record<SearchRange, string> = {
today: '今天',
'7d': '近 7 天',
'30d': '近 30 天',
all: '全部历史'
}
export const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v8'
export const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1'
export const SEARCH_CACHE_LIMIT = 20
export const currentTimestamp = (): number => Date.now()
const SEARCH_INTENT_PHRASES = [
'全局搜一下',
'全局搜索',
'搜索一下',
'搜一下',
'查询一下',
'查一下',
'找一下',
'我和谁聊过',
'谁和我聊过',
'哪些人和我聊过',
'最近讨论了什么',
'最近聊了什么',
'最近说了什么',
'讨论了什么',
'讨论什么',
'聊了什么',
'聊些什么',
'说了什么',
'说些什么',
'最近讨论',
'最近聊天',
'这个话题',
'相关话题',
'的聊天',
'的内容',
'的记录',
'关于',
'聊天',
'记录',
'聊天记录',
'帮我',
'请问'
].sort((left, right) => right.length - left.length)
const SEARCH_STOP_WORDS = new Set([
'我',
'谁',
'什么',
'哪些',
'哪个',
'人',
'和',
'聊过',
'说过',
'提到',
'讨论',
'聊天',
'记录',
'说',
'聊',
'话题',
'内容',
'相关',
'最近',
'一下'
])
export const messageText = (message: Message): string =>
String(message.content || '').trim() || `[${message.type || '消息'}]`
export const normalizeSearchText = (value: string): string => value.toLowerCase().replace(/\s+/g, '')
export const includesSearchAlias = (query: string, alias: string): boolean => {
const normalizedAlias = normalizeSearchText(alias.trim())
return Boolean(normalizedAlias) && normalizeSearchText(query).includes(normalizedAlias)
}
const extractSearchKeywords = (query: string): string[] => {
const cleanedQuery = SEARCH_INTENT_PHRASES.reduce(
(value, phrase) => value.split(phrase).join(' '),
query.toLowerCase()
)
const tokens = cleanedQuery
.split(/[\s,,。!?!?、:;"“”‘’()()[\]【】]+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token))
return Array.from(new Set(tokens))
}
const getFuzzySearchKeywords = (keywords: string[]): string[] =>
Array.from(
new Set(
keywords.flatMap((keyword) => {
const variants = [keyword]
if (/^[\u4e00-\u9fff]+$/.test(keyword) && keyword.length > 2) {
variants.push(keyword.slice(-2))
}
return variants
})
)
)
const normalizeSearchTerms = (terms: unknown): string[] => {
if (!Array.isArray(terms)) return []
return Array.from(
new Set(
terms
.filter((term): term is string => typeof term === 'string')
.map((term) => term.trim())
.filter((term) => term.length >= 2 && term.length <= 32)
)
).slice(0, 16)
}
export const buildLocalSearchPlan = (query: string): SearchQueryPlan => {
const keywords = extractSearchKeywords(query)
const asksParticipants = /我和谁|谁和我|哪些人|哪个人|哪些联系人/.test(query)
const intent: SearchIntent = asksParticipants
? keywords.length
? 'mixed'
: 'participants'
: keywords.length
? 'topic'
: 'general'
return {
intent,
keywords,
variants: getFuzzySearchKeywords(keywords),
source: 'local'
}
}
export const parseSearchPlanResponse = (value: string): Partial<SearchQueryPlan> | null => {
const jsonMatch = value.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
const intent = ['general', 'topic', 'participants', 'mixed'].includes(String(parsed.intent))
? (parsed.intent as SearchIntent)
: undefined
return {
intent,
keywords: normalizeSearchTerms(parsed.keywords),
variants: normalizeSearchTerms(parsed.variants)
}
} catch {
return null
}
}
export const mergeSearchPlans = (
localPlan: SearchQueryPlan,
aiPlan: Partial<SearchQueryPlan> | null
): SearchQueryPlan => {
if (!aiPlan) return localPlan
const keywords = normalizeSearchTerms([...(localPlan.keywords || []), ...(aiPlan.keywords || [])])
const variants = normalizeSearchTerms([
...getFuzzySearchKeywords(keywords),
...(localPlan.variants || []),
...(aiPlan.variants || [])
])
return {
intent: aiPlan.intent || localPlan.intent,
keywords,
variants,
source: 'hybrid'
}
}
export const messageIdentity = (message: Message): string =>
message.localId
? `local:${message.localId}`
: message.id || `${message.createTime}:${message.content}`
export const evidenceIdentity = ({ contact, message }: EvidenceItem): string =>
`${contact.md5}:${messageIdentity(message)}`
export const formatMessageTime = (message: Message): string => {
if (message.datetime) return message.datetime
if (message.createTime) return new Date(message.createTime * 1000).toLocaleString('zh-CN')
return '未知时间'
}
export const getRangeStart = (range: SearchRange): number | undefined => {
if (range === 'all') return undefined
if (range === 'today') {
const now = new Date()
return Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
}
const days = range === '7d' ? 7 : 30
return Math.floor(Date.now() / 1000) - days * 86400
}
export const messageDateKey = (message: Message): string => {
if (!message.createTime) return 'unknown'
const date = new Date(message.createTime * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export const formatMessageDate = (dateKey: string): string => {
if (dateKey === 'unknown') return '--/--'
const [, month, day] = dateKey.split('-')
return `${month}/${day}`
}
export const selectEvenly = <T,>(items: T[], count: number): T[] => {
if (count >= items.length) return items
if (count <= 0) return []
if (count === 1) return [items[items.length - 1]]
return Array.from({ length: count }, (_item, index) => {
const itemIndex = Math.round((index * (items.length - 1)) / (count - 1))
return items[itemIndex]
})
}
export const selectEvidenceByDate = (items: EvidenceItem[], maxItems: number): EvidenceItem[] => {
const buckets = new Map<string, EvidenceItem[]>()
items.forEach((item) => {
const key = messageDateKey(item.message)
const bucket = buckets.get(key) || []
bucket.push(item)
buckets.set(key, bucket)
})
const entries = Array.from(buckets.entries()).sort(([left], [right]) => left.localeCompare(right))
const targetCount = Math.min(maxItems, items.length)
const counts = entries.map(() => 0)
let remaining = targetCount
while (remaining > 0) {
let bestIndex = -1
let bestRemaining = 0
entries.forEach(([, bucket], index) => {
const available = bucket.length - counts[index]
if (available > bestRemaining) {
bestIndex = index
bestRemaining = available
}
})
if (bestIndex < 0) break
counts[bestIndex] += 1
remaining -= 1
}
return entries
.flatMap((entry, index) => selectEvenly(entry[1], counts[index]))
.sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0))
}
const looksLikeUserId = (value: string): boolean =>
value.startsWith('wxid_') || value.includes('@chatroom') || /^\d{6,}$/.test(value)
export const formatMemberName = (member: GroupMemberName): string =>
member.groupNickname || member.wechatNickname || member.nickname || member.remark || member.wxid
export const senderName = (message: Message, contact: Contact, names: Record<string, string>): string => {
const identifiers = [message.senderId, message.from, message.name].filter(
(value): value is string => Boolean(value?.trim())
)
const mappedName = identifiers.map((value) => names[value]).find(Boolean)
if (mappedName) return mappedName
if (message.name && !looksLikeUserId(message.name)) return message.name
if (message.isSender) return '我'
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
}
export const compactCacheItem = ({ contact, message }: EvidenceItem): EvidenceItem => ({
contact: {
md5: contact.md5,
m_nsUsrName: contact.m_nsUsrName,
m_nsNickName: contact.m_nsNickName,
type: contact.type,
avatar: contact.avatar
},
message: {
id: message.id,
from: message.from,
type: message.type,
datetime: message.datetime,
content: message.content,
isSender: message.isSender,
name: message.name,
senderId: message.senderId,
localId: message.localId,
serverId: message.serverId,
createTime: message.createTime
}
})
export const buildSearchCacheKey = (
scope: SearchScope,
contactMd5: string,
range: SearchRange,
query: string
): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()])
export const parseSearchCacheKey = (
key: string
): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string } | null => {
try {
const parts = JSON.parse(key) as unknown
if (
!Array.isArray(parts) ||
!['global', 'conversation'].includes(String(parts[0])) ||
!['today', '7d', '30d', 'all'].includes(String(parts[2])) ||
typeof parts[3] !== 'string'
) {
return null
}
return {
scope: parts[0] as SearchScope,
contactMd5: typeof parts[1] === 'string' ? parts[1] : '',
range: parts[2] as SearchRange,
query: parts[3]
}
} catch {
return null
}
}
export const readSearchCache = (key: string): AISearchCacheRecord | null => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const record = records.find((item) => item.version === 1 && item.key === key)
return record || null
} catch {
return null
}
}
export const readSearchCacheByQuery = (
query: string
): { record: AISearchCacheRecord; location: ReturnType<typeof parseSearchCacheKey> } | null => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const normalizedQuery = query.trim().toLowerCase()
for (const record of records) {
const location = parseSearchCacheKey(record.key)
if (record.version === 1 && location?.query === normalizedQuery) {
return { record, location }
}
}
return null
} catch {
return null
}
}
export const writeSearchCache = (record: AISearchCacheRecord): void => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const nextRecords = [record, ...records.filter((item) => item.key !== record.key)].slice(
0,
SEARCH_CACHE_LIMIT
)
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords))
} catch {
// A large message result must not break the search itself.
}
}
@@ -195,8 +195,9 @@ export function AgentHubWorkspace(): React.ReactElement {
<h2></h2>
<p> Agent Hub WechatExplorer</p>
<div className="agent-hub-example">
<span></span>
<span></span>
<strong> 5 </strong>
<strong>xx聊了些什么</strong>
<strong></strong>
</div>
<ul>
@@ -212,6 +213,10 @@ export function AgentHubWorkspace(): React.ReactElement {
<i />
</li>
<li>
<i />
使 AI
</li>
<li>
<i className={status.dataApi === 'online' ? '' : 'offline'} />
API{status.dataApi === 'online' ? '已连接' : '未连接'}
@@ -6,6 +6,11 @@ import { AccountDatabasePage } from './pages/AccountDatabasePage'
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
import { AIModelPage } from './pages/AIModelPage'
import { RecallProtectionPage } from './pages/RecallProtectionPage'
import { AdvancedPage } from './pages/AdvancedPage'
import { CacheCleanupPage } from './pages/CacheCleanupPage'
import { AppearancePage } from './pages/AppearancePage'
import { AboutPage } from './pages/AboutPage'
import type { Contact } from '../../../../shared/types'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
@@ -23,7 +28,8 @@ export function SettingsWorkspace({
onReturnToLogin,
onAIRuntimeChange,
onNotice,
onOpenSettings
onOpenSettings,
onAppearanceChange
}: {
selectedCategory: SettingsCategoryId
onCategoryChange: (id: SettingsCategoryId) => void
@@ -39,6 +45,7 @@ export function SettingsWorkspace({
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void
onOpenSettings: () => void
onAppearanceChange: (settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => void
}): React.ReactElement {
return (
<div className="settings-workspace">
@@ -49,14 +56,17 @@ export function SettingsWorkspace({
dbReady={dbReady}
onOpenSettings={onOpenSettings}
/>
{selectedCategory === 'account-database' ? (
<div
className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}
>
<AccountDatabasePage
dbKey={dbKey}
dbReady={dbReady}
selfInfo={selfInfo}
onNotice={onNotice}
/>
) : selectedCategory === 'database-key' ? (
</div>
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
<DatabaseKeyPage
dbKey={dbKey}
dbReady={dbReady}
@@ -69,12 +79,44 @@ export function SettingsWorkspace({
onReturnToLogin={onReturnToLogin}
onNotice={onNotice}
/>
) : selectedCategory === 'image-key' ? (
</div>
<div className={`settings-page-panel ${selectedCategory === 'image-key' ? 'active' : ''}`}>
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
) : selectedCategory === 'ai-model' ? (
</div>
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
) : (
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
</div>
<div
className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}
>
<RecallProtectionPage onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'advanced' ? 'active' : ''}`}>
<AdvancedPage onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'cache-cleanup' ? 'active' : ''}`}>
<CacheCleanupPage onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'appearance' ? 'active' : ''}`}>
<AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'about' ? 'active' : ''}`}>
<AboutPage onNotice={onNotice} />
</div>
{![
'account-database',
'database-key',
'image-key',
'ai-model',
'recall-protection',
'advanced',
'cache-cleanup',
'appearance',
'about'
].includes(selectedCategory) && (
<div className="settings-page-panel active">
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
</div>
)}
</div>
)
@@ -191,7 +191,9 @@ export function useDatabaseKeyController({
])
const returnToLogin = useCallback(async (): Promise<void> => {
const result = await window.api.disconnectDb()
// macOS WCDB 的 close 会关闭进程级原生运行时,返回登录时只退出 UI 连接态。
// 用户再次点击连接后由 db:init 复用已验证的本机连接。
const result = await window.api.disconnectDb({ closeNative: false })
if (!result.success) {
onNotice(result.error || '断开数据库连接失败')
return
@@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({
{state.status.platform === 'darwin'
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
<br />
<small className="image-auto-scope-hint">
WeChat 4.0V3
</small>
</p>
</div>
<button
@@ -7,19 +7,10 @@ export function ImageKeyConfiguration({
}: {
state: ImageDecryptionState
disabled: boolean
onEdit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
onEdit: (field: 'xorKey' | 'aesKey', value: string) => void
}): React.ReactElement {
return (
<section className="settings-card image-key-editor">
<label>
<span></span>
<input
value={state.resourceRoot}
disabled={disabled}
title={state.resourceRoot}
onChange={(event) => onEdit('resourceRoot', event.target.value)}
/>
</label>
<div className="image-key-grid">
<label>
<span>XOR Key</span>
@@ -1,5 +1,58 @@
import type { ImageDecryptionState } from './types'
type StepState = 'pending' | 'ok' | 'fail' | 'skipped'
function stepClass(step: StepState): string {
switch (step) {
case 'ok':
return 'image-step image-step-ok'
case 'fail':
return 'image-step image-step-fail'
case 'skipped':
return 'image-step image-step-skipped'
default:
return 'image-step image-step-pending'
}
}
function stepIcon(step: StepState): string {
switch (step) {
case 'ok':
return '✓'
case 'fail':
return '×'
case 'skipped':
return '·'
default:
return '○'
}
}
function pickSteps(result: {
fileFound: boolean
decrypted: boolean
readable: boolean
success: boolean
}): { find: StepState; decrypt: StepState; read: StepState } {
// 三步严格联动:找到失败 → 解密/读取 skipped;解密失败 → 读取 skipped
// 解密成功但不可读 → 读取 fail。
if (!result.fileFound) {
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
}
if (!result.success && !result.decrypted && !result.readable) {
// 后端把 fileFound=false 的情况也用 success:false 表达;
// 此时第一步直接 fail,后两步 skip。
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
}
if (!result.decrypted) {
return { find: 'ok', decrypt: 'fail', read: 'skipped' }
}
if (!result.readable) {
return { find: 'ok', decrypt: 'ok', read: 'fail' }
}
return { find: 'ok', decrypt: 'ok', read: 'ok' }
}
export function ImageTestSection({
state,
disabled,
@@ -16,6 +69,14 @@ export function ImageTestSection({
onSave: () => void
}): React.ReactElement {
const result = state.testResult
const steps = result
? pickSteps({
fileFound: result.fileFound,
decrypted: result.decrypted,
readable: result.readable,
success: result.success
})
: null
return (
<section className="settings-card image-test-section">
<div>
@@ -48,16 +109,27 @@ export function ImageTestSection({
</button>
</div>
{result ? (
<div className={`image-test-result ${result.success ? 'success' : 'error'}`}>
<span>{result.fileFound ? '✓' : '×'} </span>
<span>{result.decrypted ? '✓' : '×'} </span>
<span>{result.readable ? '✓' : '×'} </span>
{!result.success ? <p>{result.error}</p> : null}
</div>
{steps ? (
<ol className="image-step-list">
<li className={stepClass(steps.find)}>
<span className="image-step-icon">{stepIcon(steps.find)}</span>
<span></span>
</li>
<li className={stepClass(steps.decrypt)}>
<span className="image-step-icon">{stepIcon(steps.decrypt)}</span>
<span></span>
</li>
<li className={stepClass(steps.read)}>
<span className="image-step-icon">{stepIcon(steps.read)}</span>
<span></span>
</li>
</ol>
) : state.error ? (
<p className="image-inline-error">{state.error}</p>
) : null}
{result && !result.success && result.error ? (
<p className="image-inline-error">{result.error}</p>
) : null}
</section>
)
}
@@ -27,7 +27,11 @@ export function imageDecryptionReducer(
config: action.config,
status: action.status,
contacts: action.contacts,
resourceRoot: action.config.resourceRoot,
// 下方"图片资源目录"跟随状态面板的默认目录同步;
// 一旦用户手动编辑过(dirty),就不再被刷新覆盖。
resourceRoot: state.dirty && state.resourceRoot
? state.resourceRoot
: action.status.resourceRoot || action.config.resourceRoot,
xorKey: action.config.xorKey || '0x40',
aesKey: action.config.aesKey || '',
error: action.config.success ? undefined : action.config.error,
@@ -126,7 +130,7 @@ export function imageDecryptionReducer(
config: action.config,
status: action.status,
contacts: state.contacts,
resourceRoot: action.config.resourceRoot
resourceRoot: action.status.resourceRoot || action.config.resourceRoot
}
default:
return state
@@ -53,7 +53,7 @@ export type ImageDecryptionAction =
contacts: Contact[]
}
| { type: 'LOAD_ERROR'; error: string }
| { type: 'EDIT'; field: 'resourceRoot' | 'xorKey' | 'aesKey'; value: string }
| { type: 'EDIT'; field: 'xorKey' | 'aesKey'; value: string }
| { type: 'SELECT_CHAT'; userMd5: string }
| { type: 'TEST_START' }
| { type: 'TEST_DONE'; result: ImageDecryptionTestResult }
@@ -80,7 +80,7 @@ export interface ImageDecryptionController {
pageStatus: 'configured' | 'unconfigured' | 'partial'
busy: boolean
canSave: boolean
edit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
edit: (field: 'xorKey' | 'aesKey', value: string) => void
selectChat: (userMd5: string) => void
test: () => Promise<void>
save: () => Promise<void>
@@ -33,7 +33,7 @@ export function useImageDecryptionController({
})
}, [refresh])
const edit = useCallback((field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string): void => {
const edit = useCallback((field: 'xorKey' | 'aesKey', value: string): void => {
dispatch({ type: 'EDIT', field, value })
}, [])
@@ -84,12 +84,17 @@ export function useImageDecryptionController({
dispatch({ type: 'AUTO_START' })
const result = await window.api.autoGetImageKey({ save: false })
if (!result.success || !result.aesKey || !result.verified) {
dispatch({
type: 'AUTO_ERROR',
error: result.success
? '获取到候选密钥,但未通过图片验证'
: sanitizeImageError(result.error)
})
// 自动获取链路:原文透传后端错误信息,不要走 sanitizeImageError。
// sanitizeImageError 是给"测试图片解析"设计的字典,会把
// "未找到 V2 模板文件 / 微信进程未运行 / 60 秒未扫描到密钥" 等
// 完全合法的扫描阶段错误强制翻成"无法解析媒体文件"。
const rawError = (result.error || '').toString().trim()
const errorMessage = !result.success
? rawError || '自动获取图片密钥失败'
: !result.aesKey
? '自动获取未返回 AES 密钥'
: '获取到候选密钥,但未通过图片验证'
dispatch({ type: 'AUTO_ERROR', error: errorMessage })
return
}
dispatch({
@@ -11,11 +11,17 @@ export function formatImageConfigTime(value?: number): string {
export function sanitizeImageError(error?: string): string {
const value = String(error || '').toLowerCase()
if (value.includes('no_image_message') || value.includes('300')) {
return '当前会话最近 300 条消息内没有图片,请换一个含图片的聊天再测试'
}
if (value.includes('unsupported') || value.includes('dat version')) {
return '仅支持 WeChat 4.0 图片协议,V3 及以下无法解析'
}
if (value.includes('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配'
if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在'
if (value.includes('账号')) return '当前账号不匹配'
if (value.includes('目录')) return '图片资源目录不可用'
return error ? '无法解析媒体文件' : '图片解析测试未通过'
return error ? '无法解析媒体文件(仅支持 WeChat 4.0' : '图片解析测试未通过'
}
export function normalizeAutoXorKey(value?: number, formatted?: string): string {
@@ -18,7 +18,6 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
label: '智能能力',
items: [
{ id: 'ai-model', label: 'AI 模型' },
{ id: 'local-api', label: '本地 API' }
]
},
{
@@ -31,6 +30,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
{
label: '应用',
items: [
{ id: 'recall-protection', label: '防撤回' },
{ id: 'appearance', label: '外观与行为' },
{ id: 'advanced', label: '高级' },
{ id: 'about', label: '关于' }
@@ -3,6 +3,7 @@ export type SettingsCategoryId =
| 'database-key'
| 'image-key'
| 'ai-model'
| 'recall-protection'
| 'local-api'
| 'storage-export'
| 'cache-cleanup'
@@ -0,0 +1,91 @@
import { useEffect, useMemo, useState } from 'react'
import type { AppUpdateState } from '../../../../../shared/app-update'
const REPOSITORY_URL = 'https://github.com/Wxw-Gu/WechatExplorer'
const RELEASES_URL = `${REPOSITORY_URL}/releases`
function formatBytes(value?: number): string {
if (!value) return ''
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB/s`
return `${(value / 1024 / 1024).toFixed(1)} MB/s`
}
export function AboutPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
const [update, setUpdate] = useState<AppUpdateState>({ status: 'idle', currentVersion: '读取中...' })
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
void window.api.getAppUpdateState().then((state) => active && setUpdate(state))
const unsubscribe = window.api.onAppUpdateState((state) => {
if (active) setUpdate(state)
})
return () => {
active = false
unsubscribe()
}
}, [])
const action = useMemo(() => {
if (update.status === 'downloaded') return '重启并安装'
if (update.status === 'available') return '下载更新'
if (update.status === 'checking' || update.status === 'downloading') return '处理中...'
return '检查更新'
}, [update.status])
const runUpdate = async (): Promise<void> => {
setBusy(true)
try {
if (update.status === 'downloaded') {
const result = await window.api.installAppUpdate()
if (!result.success) onNotice(result.error || '更新安装失败')
} else if (update.status === 'available') {
await window.api.downloadAppUpdate()
} else {
await window.api.checkAppUpdate()
}
} finally {
setBusy(false)
}
}
return (
<div className="settings-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p>WechatExplorer </p>
</div>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
<section className="settings-card about-identity-card">
<div><span className="settings-card-kicker"></span><strong>WechatExplorer</strong><small>v{update.currentVersion}</small></div>
<a href={REPOSITORY_URL} target="_blank" rel="noreferrer">GitHub </a>
</section>
<h2 className="settings-section-heading"></h2>
<section className={`settings-card update-card status-${update.status}`}>
<div className="update-card-copy">
<strong>{update.status === 'available' || update.status === 'downloaded' ? `发现 v${update.version}` : update.message || '检查 GitHub Releases 获取最新版本'}</strong>
<span>
{update.status === 'downloading'
? `正在下载 ${Math.round(update.percent || 0)}% · ${formatBytes(update.bytesPerSecond)}`
: '会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。'}
</span>
{update.status === 'downloading' && <div className="update-progress"><i style={{ width: `${update.percent || 0}%` }} /></div>}
</div>
<button type="button" className="settings-primary-button" disabled={busy || update.status === 'checking' || update.status === 'downloading'} onClick={() => void runUpdate()}>{action}</button>
</section>
<h2 className="settings-section-heading"></h2>
<section className="settings-card about-links-card">
<a href={RELEASES_URL} target="_blank" rel="noreferrer"></a>
<button type="button" onClick={() => void window.api.revealAppLog()}></button>
</section>
<p className="settings-footnote"> AI </p>
</div>
</div>
</div>
)
}
@@ -31,7 +31,8 @@ export function AccountDatabasePage({
useEffect(() => {
let active = true
void window.api.getSettings().then((result) => {
if (active) setAutoLogin(result.settings.autoLogin)
if (!active) return
setAutoLogin(result.settings.autoLogin)
})
return () => {
active = false
@@ -46,6 +47,7 @@ export function AccountDatabasePage({
setAutoLogin(result.settings.autoLogin)
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
}
return (
<div className="settings-page">
<header className="settings-page-header">
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
export function AdvancedPage({
onNotice
}: {
onNotice: (message: string) => void
}): React.ReactElement {
const [debugEnabled, setDebugEnabled] = useState(false)
useEffect(() => {
let active = true
void window.api.getSettings().then((result) => {
if (active) setDebugEnabled(result.settings.debugEnabled)
})
return () => {
active = false
}
}, [])
const changeDebugEnabled = async (checked: boolean): Promise<void> => {
const result = await window.api.setSettings({ debugEnabled: checked })
setDebugEnabled(result.settings.debugEnabled)
onNotice(checked ? '已开启调试日志' : '已关闭调试日志')
}
return (
<div className="settings-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p></p>
</div>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-debug-card">
<label>
<span>
<b></b>
<small></small>
</span>
<input
type="checkbox"
checked={debugEnabled}
onChange={(event) => void changeDebugEnabled(event.target.checked)}
/>
</label>
<div className="settings-debug-actions">
<button type="button" onClick={() => void window.api.revealAppLog()}>
</button>
<small></small>
</div>
</section>
</div>
</div>
</div>
)
}
@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react'
export type AppearanceTheme = 'system' | 'light' | 'dark'
export function AppearancePage({
onNotice,
onAppearanceChange
}: {
onNotice: (message: string) => void
onAppearanceChange: (settings: { theme: AppearanceTheme; compactMode: boolean }) => void
}): React.ReactElement {
const [theme, setTheme] = useState<AppearanceTheme>('system')
const [compactMode, setCompactMode] = useState(false)
const [showStartupProgress, setShowStartupProgress] = useState(true)
useEffect(() => {
let active = true
void window.api.getSettings().then((result) => {
if (!active) return
setTheme(result.settings.appearanceTheme)
setCompactMode(result.settings.compactMode)
setShowStartupProgress(result.settings.showStartupProgress)
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode })
})
return () => {
active = false
}
}, [onAppearanceChange])
const save = async (patch: {
appearanceTheme?: AppearanceTheme
compactMode?: boolean
showStartupProgress?: boolean
}): Promise<void> => {
const result = await window.api.setSettings(patch)
setTheme(result.settings.appearanceTheme)
setCompactMode(result.settings.compactMode)
setShowStartupProgress(result.settings.showStartupProgress)
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode })
onNotice('外观设置已保存')
}
return (
<div className="settings-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p></p>
</div>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-option-card">
<div className="settings-choice-grid">
{([
['system', '跟随系统', '根据 macOS 或 Windows 外观自动切换'],
['light', '浅色', '保持当前清爽的浅色工作区'],
['dark', '深色', '降低夜间浏览时的亮度']
] as const).map(([value, label, hint]) => (
<label className={`settings-choice ${theme === value ? 'active' : ''}`} key={value}>
<input type="radio" name="appearance-theme" checked={theme === value} onChange={() => void save({ appearanceTheme: value })} />
<span><b>{label}</b><small>{hint}</small></span>
</label>
))}
</div>
</section>
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-toggle-list">
<label className="settings-toggle-row">
<span><b></b><small></small></span>
<input type="checkbox" checked={compactMode} onChange={(event) => void save({ compactMode: event.target.checked })} />
</label>
<label className="settings-toggle-row">
<span><b></b><small></small></span>
<input type="checkbox" checked={showStartupProgress} onChange={(event) => void save({ showStartupProgress: event.target.checked })} />
</label>
</section>
</div>
</div>
</div>
)
}
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from 'react'
import type { CacheSummary } from '../../../../../shared/cache'
const SEARCH_CACHE_KEYS = ['wxe_ai_search_cache_v8', 'wxe_ai_search_history_v1', 'wxe_export_tasks']
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`
}
export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
const [summary, setSummary] = useState<CacheSummary | null>(null)
const [busyScope, setBusyScope] = useState<'bootstrap' | 'electron' | 'all' | 'local' | null>(null)
const refresh = useCallback(async (): Promise<void> => {
setSummary(await window.api.getCacheSummary())
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const clearLocal = (): void => {
setBusyScope('local')
for (const key of SEARCH_CACHE_KEYS) localStorage.removeItem(key)
setBusyScope(null)
onNotice('已清理检索和导出本地缓存')
}
const clear = async (scope: 'bootstrap' | 'electron' | 'all'): Promise<void> => {
setBusyScope(scope)
try {
if (scope === 'all') {
for (const key of SEARCH_CACHE_KEYS) localStorage.removeItem(key)
}
setSummary(await window.api.clearCache(scope))
onNotice(scope === 'all' ? '已清理全部可恢复缓存和检索记录' : '缓存已清理')
} finally {
setBusyScope(null)
}
}
return (
<div className="settings-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p></p>
</div>
<button type="button" className="settings-header-action" onClick={() => void refresh()}>
</button>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
<section className="settings-card cache-overview-card">
<div>
<span className="settings-card-kicker"></span>
<strong>{formatBytes(summary?.totalBytes || 0)}</strong>
<small></small>
</div>
<button
type="button"
className="settings-danger-button"
disabled={busyScope !== null}
onClick={() => void clear('all')}
>
{busyScope === 'all' ? '清理中...' : '清理全部'}
</button>
</section>
<h2 className="settings-section-heading"></h2>
<div className="settings-cache-list">
{summary?.items.map((item) => (
<section className="settings-card settings-cache-item" key={item.id}>
<div>
<h3>{item.label}</h3>
<p>{item.description}</p>
<small>{formatBytes(item.sizeBytes)} · {item.fileCount} </small>
</div>
<button
type="button"
disabled={busyScope !== null}
onClick={() => void clear(item.id)}
>
{busyScope === item.id ? '清理中...' : '清理'}
</button>
</section>
))}
<section className="settings-card settings-cache-item">
<div>
<h3></h3>
<p></p>
<small></small>
</div>
<button type="button" disabled={busyScope !== null} onClick={clearLocal}>
{busyScope === 'local' ? '清理中...' : '清理'}
</button>
</section>
</div>
<div className="settings-inline-note">
<strong></strong>
<span></span>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
export function RecallProtectionPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
const [enabled, setEnabled] = useState(false)
useEffect(() => {
let active = true
void window.api.getSettings().then((result) => {
if (active) setEnabled(result.settings.recallProtectionEnabled)
})
return () => {
active = false
}
}, [])
const changeEnabled = async (checked: boolean): Promise<void> => {
const result = await window.api.setSettings({ recallProtectionEnabled: checked })
setEnabled(result.settings.recallProtectionEnabled)
onNotice(checked ? '已开启防撤回' : '已关闭防撤回')
}
return (
<div className="settings-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p></p>
</div>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-recall-card">
<div className="settings-recall-grid">
<label className="settings-recall-option">
<span>
<b></b>
<small>便</small>
</span>
<input
type="checkbox"
checked={enabled}
onChange={(event) => void changeEnabled(event.target.checked)}
/>
</label>
<aside className="settings-recall-notice">
<strong></strong>
<span></span>
</aside>
</div>
</section>
</div>
</div>
</div>
)
}
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../shared/types'
import {
buildGroupReportInput,
getSummaryDateRange,
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
GROUP_REPORT_SYSTEM_PROMPT,
isInternalName,
parseGroupDailyReport,
@@ -108,6 +109,31 @@ const withTimeout = async <T>(
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error)
const writeReportLog = (
level: 'info' | 'warn' | 'error',
message: string,
details?: Record<string, unknown>
): void => {
void window.api
.writeAppLog({ level, scope: 'group-report', message, details })
.catch(() => undefined)
}
const jsonErrorContext = (raw: string, error: unknown): Record<string, unknown> => {
const message = errorMessage(error)
const position = Number(/\bposition\s+(\d+)/i.exec(message)?.[1])
const safePosition = Number.isFinite(position) ? Math.max(0, Math.min(raw.length, position)) : 0
return {
error: message,
outputLength: raw.length,
position: Number.isFinite(position) ? position : undefined,
context:
raw.length && Number.isFinite(position)
? raw.slice(Math.max(0, safePosition - 300), Math.min(raw.length, safePosition + 300))
: raw.slice(0, 600)
}
}
const isGroupContact = (contact: Contact | null): boolean =>
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
@@ -136,6 +162,16 @@ const estimateTokenUsage = (
}
}
const mergeTokenUsage = (
first: NonNullable<ReportGenerationMetadata['tokenUsage']>,
second: NonNullable<ReportGenerationMetadata['tokenUsage']>
): NonNullable<ReportGenerationMetadata['tokenUsage']> => ({
input: (first.input || 0) + (second.input || 0),
output: (first.output || 0) + (second.output || 0),
total: (first.total || 0) + (second.total || 0),
estimated: Boolean(first.estimated || second.estimated)
})
const applyGroupMemberNames = async (
contact: Contact,
messages: Message[],
@@ -175,13 +211,18 @@ const applyGroupMemberNames = async (
const member = memberMap.get(senderId)
if (!member) return message
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
groupNickname: [member.groupNickname, member.wechatNickname, member.remark, member.nickname],
wechatNickname: [member.wechatNickname, member.groupNickname, member.remark, member.nickname],
// Keep the three modes semantically distinct. `member.nickname` may
// already be a contact remark, so it must not leak into the first two.
groupNickname: [member.groupNickname, member.wechatNickname],
wechatNickname: [member.wechatNickname, member.groupNickname],
remark: [member.remark, member.groupNickname, member.wechatNickname, member.nickname]
}
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
if (!name) return message
return { ...message, name, img: message.img || member.avatar }
return {
...message,
name: name || (preference === 'remark' ? message.name : ''),
img: message.img || member.avatar
}
})
}
@@ -222,11 +263,12 @@ export function useGroupReportGeneration({
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
const [memberNamePreference, setMemberNamePreferenceState] =
useState<ReportMemberNamePreference>(() => {
const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>(
() => {
const saved = localStorage.getItem('group_report_member_name_preference')
return saved === 'wechatNickname' || saved === 'remark' ? saved : 'groupNickname'
})
}
)
const [reportTimeoutSeconds, setReportTimeoutSecondsState] = useState<number>(() => {
const saved = Number(localStorage.getItem('group_report_timeout_seconds'))
return Number.isFinite(saved) && saved >= 30 ? saved : 300
@@ -352,6 +394,7 @@ export function useGroupReportGeneration({
}
const startGenerateTime = Date.now()
let failedAt = '初始化'
const logs: ReportGenerationLog[] = []
const pushLog = (log: ReportGenerationLog): void => {
logs.push(log)
@@ -382,15 +425,29 @@ export function useGroupReportGeneration({
modelName: modelConfig.model,
generationLogs: []
})
writeReportLog('info', '开始生成群聊日报', {
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
dateRange: summaryDateRange,
selectedMessageTypes: summaryMessageTypes,
providerName: modelConfig.providerName,
model: modelConfig.model,
templateId
})
try {
failedAt = '读取聊天记录'
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
writeReportLog('info', '聊天记录读取完成', {
sourceMessageCount: sourceMessages.length,
filteredMessageCount: filteredMessages.length
})
setPhase('preparingInput')
failedAt = '整理日报输入'
const input = await trackStep('整理输入', async () => {
const namedReportMessages = await applyGroupMemberNames(
sourceContact,
@@ -401,6 +458,7 @@ export function useGroupReportGeneration({
})
setPhase('requestingModel')
failedAt = '调用模型生成内容'
const aiMessages = [
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
@@ -417,22 +475,82 @@ export function useGroupReportGeneration({
)
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
writeReportLog('info', '模型响应完成', {
outputLength: result.data.length,
usage: result.usage
})
const tokenUsage =
let tokenUsage =
result.usage && result.usage.total
? result.usage
: estimateTokenUsage(aiMessages, result.data)
const report = parseGroupDailyReport(
result.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard || [],
input.metadata,
input.media
)
let report: ReturnType<typeof parseGroupDailyReport>
try {
report = parseGroupDailyReport(
result.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard || [],
input.metadata,
input.media
)
} catch (parseError) {
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
...jsonErrorContext(result.data, parseError),
retry: 1
})
const repairMessages = [
{
role: 'system',
content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT
},
{ role: 'user', content: result.data }
]
const repairResult = await trackStep('AI 修复 JSON', () =>
withTimeout(
window.api.aiChat(repairMessages, {
providerId: modelConfig.providerId,
modelId: modelConfig.model,
timeoutMs: reportTimeoutSeconds * 1000
}),
'AI 修复日报 JSON',
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
)
)
if (!repairResult.success || !repairResult.data) {
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', { cause: parseError })
}
const repairUsage =
repairResult.usage && repairResult.usage.total
? repairResult.usage
: estimateTokenUsage(repairMessages, repairResult.data)
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
try {
report = parseGroupDailyReport(
repairResult.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard || [],
input.metadata,
input.media
)
writeReportLog('info', '模型已纠正日报 JSON', {
retry: 1,
outputLength: repairResult.data.length
})
} catch (retryParseError) {
writeReportLog(
'error',
'日报 JSON 重试后仍解析失败',
jsonErrorContext(repairResult.data, retryParseError)
)
throw retryParseError
}
}
setPhase('exportingReport')
failedAt = '导出 HTML 与 PNG'
const exported = await withTimeout(
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
'日报图片导出'
@@ -468,8 +586,19 @@ export function useGroupReportGeneration({
generationLogs: [...logs]
})
setPhase('success')
writeReportLog('info', '群聊日报生成成功', {
durationMs: Date.now() - startGenerateTime,
htmlPath: exported.htmlPath,
pngPath: exported.pngPath
})
} catch (generateError) {
setError(errorMessage(generateError))
const message = errorMessage(generateError)
writeReportLog('error', '群聊日报生成失败', {
error: message,
failedAt,
durationMs: Date.now() - startGenerateTime
})
setError(message)
setPhase('error')
}
}, [
@@ -479,6 +608,7 @@ export function useGroupReportGeneration({
modelConfig,
reportTimeoutSeconds,
sourceContact,
summaryDateRange,
summaryMessageTypes,
templateId
])
+32 -2
View File
@@ -1,8 +1,38 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/tokens.css'
import './assets/main.css'
import './styles/index.scss'
window.addEventListener('error', (event) => {
void window.api
.writeAppLog({
level: 'error',
scope: 'renderer',
message: event.message || 'Renderer 未捕获错误',
details: {
filename: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error instanceof Error ? event.error.stack : undefined
}
})
.catch(() => undefined)
})
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason
void window.api
.writeAppLog({
level: 'error',
scope: 'renderer',
message: reason instanceof Error ? reason.message : 'Renderer Promise 未处理拒绝',
details: {
stack: reason instanceof Error ? reason.stack : undefined,
reason: reason instanceof Error ? undefined : String(reason)
}
})
.catch(() => undefined)
})
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
+769
View File
@@ -0,0 +1,769 @@
@media (max-width: 700px) {
.settings-account-overview {
grid-template-columns: minmax(0, 1fr);
}
.settings-account-actions {
grid-row: auto;
grid-column: 1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-account-root {
grid-column: 1;
}
.database-key-status-card dl,
.database-key-diagnostics dl {
grid-template-columns: 1fr;
}
.database-key-auto-heading {
flex-direction: column;
}
.database-key-phases {
grid-template-columns: 1fr;
}
.database-key-phases li {
padding: 0 0 0 28px;
text-align: left;
}
.database-key-phases li::before {
top: -2px;
left: 0;
transform: none;
}
.image-decrypt-status dl,
.image-key-grid {
grid-template-columns: 1fr;
}
.image-decrypt-status .image-decrypt-wide {
grid-column: 1;
}
.image-auto-heading {
flex-direction: column;
}
.image-auto-phases {
grid-template-columns: 1fr;
}
.image-auto-phases li {
padding: 0 0 0 28px;
text-align: left;
}
.image-auto-phases li::before {
top: -2px;
left: 0;
transform: none;
}
.image-auto-success {
grid-template-columns: 1fr;
}
.image-auto-success button {
grid-column: 1;
grid-row: auto;
justify-self: start;
}
.image-resource-checks > div {
align-items: flex-start;
}
.image-resource-checks small {
white-space: normal;
text-align: right;
}
.ai-provider-card dl,
.ai-provider-form-grid,
.ai-provider-advanced > div {
grid-template-columns: 1fr;
}
.ai-provider-form-grid .wide,
.ai-provider-advanced .wide {
grid-column: 1;
}
.ai-model-row {
grid-template-columns: 1fr;
}
}
.api-center-layout > * {
min-width: 0;
min-height: 0;
box-sizing: border-box;
}
.api-section-heading h2,
.api-introduction h2,
.api-integrations h2,
.api-runtime-title h2 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 17px/24px var(--wxex-font);
}
.ready-text {
color: var(--wxex-success);
}
.api-main {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--wxex-bg-main);
}
.api-main-scroll {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 22px 28px 36px;
}
.api-workspace-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--wxex-border);
}
.api-title-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.api-title-line h1 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 22px/30px var(--wxex-font);
}
.api-workspace-heading p {
margin: 4px 0 0;
color: var(--wxex-text-secondary);
font: 13px/19px var(--wxex-font);
}
.api-skill-status,
.api-version {
padding: 2px 7px;
border-radius: 5px;
font: 700 11px/17px var(--wxex-font);
}
.api-skill-status.ready {
color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
.api-skill-status.error {
color: var(--wxex-danger);
background: #fff1f1;
}
.api-version {
color: var(--wxex-text-muted);
background: var(--wxex-bg-app);
}
.api-header-actions,
.api-tester-actions,
.api-runtime-actions,
.api-skill-file-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.api-header-actions button,
.api-tester-actions button,
.api-runtime-actions button,
.api-endpoint-row button,
.api-response-summary + pre + p + button,
.api-skill-file-actions button {
min-height: 32px;
padding: 0 11px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.api-header-actions button:hover,
.api-tester-actions button:hover,
.api-runtime-actions button:hover,
.api-endpoint-row button:hover,
.api-skill-file-actions button:hover {
border-color: var(--wxex-brand);
color: var(--wxex-brand);
}
.api-primary-button {
border-color: var(--wxex-brand) !important;
background: var(--wxex-brand) !important;
color: #fff !important;
}
.api-primary-button:hover:not(:disabled),
.api-primary-button:focus-visible,
.api-primary-button:active {
background: var(--wxex-brand-hover) !important;
color: #fff !important;
}
.api-primary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.api-trust-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 16px;
padding: 10px 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: #f5f8f6;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-trust-bar i {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--wxex-text-muted);
}
.api-introduction {
margin-top: 24px;
padding: 18px 20px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-elevated);
}
.api-introduction p {
margin: 10px 0 16px;
color: var(--wxex-text-secondary);
font: 13px/20px var(--wxex-font);
}
.api-flow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px;
border: 1px dashed var(--wxex-border);
border-radius: var(--wxex-radius-md);
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-flow span,
.api-flow strong {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border-radius: 5px;
background: #fff;
white-space: nowrap;
}
.api-flow strong {
color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
.api-flow b {
color: var(--wxex-text-muted);
font-size: 17px;
}
.api-endpoint-catalog,
.api-request-tester {
margin-top: 26px;
}
.skill-install-flow {
margin-top: 26px;
padding: 18px 20px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-elevated);
}
.skill-flow-steps {
display: grid;
gap: 16px;
margin-top: 16px;
}
.skill-flow-steps > section {
position: relative;
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
gap: 10px;
color: var(--wxex-text-muted);
}
.skill-flow-steps > section:not(:last-child)::after {
position: absolute;
top: 28px;
left: 12px;
bottom: -16px;
width: 1px;
background: var(--wxex-border);
content: '';
}
.skill-flow-steps > section > b {
position: relative;
z-index: 1;
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: 1px solid var(--wxex-border);
border-radius: 50%;
background: var(--wxex-bg-elevated);
font: 700 12px/1 var(--wxex-font);
}
.skill-flow-steps > section.active > b,
.skill-flow-steps > section.done > b {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
.skill-flow-steps h3 {
margin: 1px 0 5px;
color: var(--wxex-text-primary);
font: 700 14px/20px var(--wxex-font);
}
.skill-flow-steps p {
margin: 3px 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.skill-flow-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.skill-flow-actions button,
.api-skill-details button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.skill-flow-actions button:disabled,
.api-skill-details button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.skill-target-selector {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.skill-target-selector button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.skill-target-selector button.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.api-skill-details {
margin-top: 16px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
.api-skill-details summary {
padding: 12px 14px;
color: var(--wxex-text-primary);
cursor: pointer;
font: 700 13px/18px var(--wxex-font);
}
.api-skill-details > dl,
.api-skill-details > div {
margin: 0;
padding: 0 14px 14px;
}
.api-skill-details dl div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
padding: 4px 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-skill-details dd {
margin: 0;
color: var(--wxex-text-primary);
}
.api-skill-more {
position: relative;
}
.api-skill-more summary {
display: grid;
min-height: 32px;
place-items: center;
padding: 0 11px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
list-style: none;
}
.api-skill-more[open] > button {
position: absolute;
z-index: 2;
top: 36px;
right: 0;
width: 170px;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
box-shadow: var(--wxex-shadow-popover);
font: 12px/18px var(--wxex-font);
text-align: left;
}
.api-section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.api-section-heading > span {
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.api-endpoint-table {
margin-top: 12px;
overflow: hidden;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
.api-endpoint-head,
.api-endpoint-row {
display: grid;
grid-template-columns: 72px minmax(128px, 1.1fr) minmax(150px, 1.2fr) 90px;
align-items: center;
gap: 12px;
padding: 11px 14px;
}
.api-endpoint-head {
background: #f1f4f2;
color: var(--wxex-text-secondary);
font: 700 12px/18px var(--wxex-font);
}
.api-endpoint-row {
min-height: 54px;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-endpoint-row.active {
background: #f7faf8;
}
.api-endpoint-row code {
color: var(--wxex-text-primary);
font:
12px/18px ui-monospace,
monospace;
}
.api-endpoint-row small {
display: block;
color: var(--wxex-text-muted);
}
.api-endpoint-row > span:last-child {
display: flex;
gap: 4px;
}
.api-endpoint-row button {
min-height: 26px;
padding: 0 7px;
font-size: 11px;
}
.api-method {
display: inline-block;
color: var(--wxex-brand);
font:
700 11px/18px ui-monospace,
monospace;
}
.api-method.post {
color: var(--wxex-ai);
}
.api-request-meta {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0;
padding: 10px 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: #f5f8f6;
overflow: hidden;
}
.api-request-meta code {
overflow: hidden;
color: var(--wxex-text-primary);
font:
12px/18px ui-monospace,
monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-param-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.api-param-grid label,
.api-json-input {
display: grid;
gap: 5px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-param-grid label span b {
margin-left: 5px;
color: var(--wxex-danger);
font-size: 11px;
}
.api-param-grid input,
.api-json-input textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
outline: none;
background: #fff;
color: var(--wxex-text-primary);
font: 12px/18px var(--wxex-font);
}
.api-param-grid input {
height: 34px;
padding: 0 9px;
}
.api-json-input {
margin-top: 10px;
}
.api-json-input textarea {
min-height: 180px;
padding: 10px;
resize: vertical;
font-family: ui-monospace, monospace;
}
.api-tester-actions {
justify-content: flex-end;
margin-top: 12px;
}
.api-inline-error {
margin: 10px 0 0;
padding: 8px 10px;
border: 1px solid rgba(200, 90, 90, 0.35);
border-radius: var(--wxex-radius-sm);
background: #fff4f4;
color: var(--wxex-danger);
font: 12px/18px var(--wxex-font);
}
.api-runtime-panel {
border-left: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
overflow: hidden;
}
.api-runtime-scroll {
height: 100%;
overflow-y: auto;
}
.api-runtime-scroll section {
padding: 18px 16px;
border-bottom: 1px solid var(--wxex-border);
}
.api-runtime-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.api-runtime-title > span {
font: 700 12px/18px var(--wxex-font);
}
.api-runtime-title .ready {
color: var(--wxex-success);
}
.api-runtime-title .stopped {
color: var(--wxex-text-muted);
}
.api-runtime-scroll h3 {
margin: 0 0 12px;
color: var(--wxex-text-primary);
font: 700 14px/20px var(--wxex-font);
}
.api-runtime-scroll dl {
margin: 14px 0;
}
.api-runtime-scroll dl div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 0;
font: 12px/18px var(--wxex-font);
}
.api-runtime-scroll dt {
color: var(--wxex-text-secondary);
}
.api-runtime-scroll dd {
margin: 0;
color: var(--wxex-text-primary);
text-align: right;
}
.warning-text {
color: var(--wxex-warning);
}
.api-runtime-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.api-runtime-actions button {
min-height: 30px;
padding: 0 6px;
}
.api-security-warning {
margin: 12px 16px;
padding: 9px 10px;
border-left: 3px solid var(--wxex-warning);
background: #fff8ec;
color: #915d1e;
font: 12px/18px var(--wxex-font);
}
.api-response-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--wxex-text-primary);
font:
12px/18px ui-monospace,
monospace;
}
.api-runtime-scroll pre {
max-height: 180px;
overflow: auto;
margin: 10px 0 8px;
padding: 10px;
border-radius: var(--wxex-radius-sm);
background: #1f2824;
color: #cde7dc;
font:
11px/16px ui-monospace,
monospace;
white-space: pre-wrap;
word-break: break-word;
}
.api-response-meta,
.api-empty-text {
margin: 0 0 9px;
color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font);
}
.api-history {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.api-history li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--wxex-border);
}
.api-history span {
overflow: hidden;
color: var(--wxex-text-primary);
font:
11px/17px ui-monospace,
monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-history b {
font: 700 11px/17px var(--wxex-font);
}
.api-history small {
grid-column: 1 / -1;
color: var(--wxex-text-muted);
font: 11px/16px var(--wxex-font);
}
.api-privacy {
background: #f5f8f6;
}
.api-privacy p {
margin: 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-markdown-overlay {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
align-items: stretch;
justify-content: center;
padding: 20px;
background: rgba(32, 39, 36, 0.24);
}
.api-markdown-overlay > div {
display: flex;
width: min(820px, 100%);
flex-direction: column;
min-height: 0;
padding: 14px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: #fff;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16);
}
.api-markdown-overlay header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.api-markdown-overlay header > div {
display: flex;
align-items: center;
gap: 8px;
}
.api-markdown-overlay header span {
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.api-markdown-overlay button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
}
.api-markdown-overlay pre,
.skill-markdown-preview {
min-height: 0;
overflow: auto;
margin: 14px 0 0;
white-space: pre-wrap;
color: var(--wxex-text-primary);
font: 12px/19px var(--wxex-font);
}
.skill-markdown-preview h1 {
font-size: 20px;
}
.skill-markdown-preview h2 {
margin-top: 18px;
font-size: 16px;
}
.skill-markdown-preview p {
margin: 6px 0;
}
.skill-markdown-preview li {
margin-left: 18px;
}
+30
View File
@@ -0,0 +1,30 @@
.archive-jump-target-group {
z-index: 2;
.archive-jump-message {
z-index: 1;
animation: archive-jump-flash 0.72s ease-in-out 2;
}
}
@keyframes archive-jump-flash {
0%,
100% {
filter: none;
}
22%,
58% {
filter: drop-shadow(0 0 0.45rem rgba(38, 128, 103, 0.68));
}
}
@media (prefers-reduced-motion: reduce) {
.archive-jump-target-group {
.archive-jump-message {
outline: 3px solid rgba(38, 128, 103, 0.58);
outline-offset: 5px;
animation: none;
}
}
}

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