Compare commits

..
25 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
109 changed files with 18938 additions and 8416 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
+29 -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.2`。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,6 +163,16 @@ 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)
+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;
}
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wechatexplorer",
"version": "2.1.4",
"version": "2.1.5",
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
"keywords": [
"wechat",
@@ -48,6 +48,7 @@
"@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",
@@ -76,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"
},
+212 -9
View File
@@ -20,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
@@ -33,6 +34,7 @@ specifiers:
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
@@ -43,6 +45,7 @@ dependencies:
'@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
@@ -71,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:
@@ -930,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'}
@@ -1429,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
@@ -1577,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==}
@@ -1766,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:
@@ -1882,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'}
@@ -2219,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}
@@ -2236,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
@@ -2739,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==}
@@ -3091,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'}
@@ -3400,7 +3564,6 @@ packages:
hasBin: true
dependencies:
argparse: 2.0.1
dev: true
/jsesc/3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
@@ -3475,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==}
@@ -3492,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
@@ -3742,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:
@@ -3962,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'}
@@ -4093,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'}
@@ -4250,9 +4437,20 @@ 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==}
@@ -4600,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'}
@@ -4784,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
@@ -4830,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: 152 KiB

After

Width:  |  Height:  |  Size: 158 KiB

+3 -2
View File
@@ -2,6 +2,7 @@ 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
@@ -52,14 +53,14 @@ export class AppLogger {
this.rotateIfNeeded()
const record = {
timestamp: new Date().toISOString(),
mode: app.isPackaged ? 'packaged' : 'development',
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 (!app.isPackaged) {
if (!isPackagedRuntime()) {
const method =
entry.level === 'error'
? console.error
+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)
}
+159 -41
View File
@@ -9,12 +9,23 @@ const imageDecryptLog = (...args: unknown[]): void => {
if (imageDecryptDebugEnabled) console.log(...args)
}
export class ImageDecryptService {
private readonly defaultV1AesKey = 'cfcd208495d565ef'
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 格式)
@@ -34,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()
@@ -71,7 +86,8 @@ export class ImageDecryptService {
}
// 返回最新的账号目录
return join(accountRoot, accounts[0].name)
this.cachedAccountDir = join(accountRoot, accounts[0].name)
return this.cachedAccountDir
}
/**
@@ -80,14 +96,32 @@ 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 || '')
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,
@@ -99,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))) {
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
return selected
return rememberPath(selected)
}
}
}
@@ -111,28 +149,75 @@ export class ImageDecryptService {
const attachDir = join(accountDir, 'msg', 'attach')
if (!existsSync(attachDir)) {
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
return rememberPath(
this.findImageFileInLegacyDirs(
accountDir,
normalizedMd5 || normalizedDatName,
allowThumbnail,
options?.preferThumbnail
)
)
}
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
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)
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
@@ -149,7 +234,7 @@ 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) {
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
return found
@@ -177,7 +262,8 @@ export class ImageDecryptService {
const found = this.getLargestExistingPath(
variants.map((variant) => join(imgDir, variant)),
allowThumbnail
allowThumbnail,
preferThumbnail
)
if (found) {
imageDecryptLog('[ImageDecrypt] found at:', found)
@@ -196,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
@@ -208,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
}
@@ -219,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())) {
imageDecryptLog('[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 {
@@ -273,12 +382,9 @@ export class ImageDecryptService {
)
let decrypted: Buffer
if (version === 1) {
imageDecryptLog('[ImageDecrypt] using V1 (default AES key)')
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
decrypted = this.decryptDatV4(datPath, key)
} else if (version === 2) {
imageDecryptLog('[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) {
imageDecryptLog('[ImageDecrypt] no AES key configured')
return null
@@ -286,7 +392,8 @@ export class ImageDecryptService {
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
decrypted = this.decryptDatV4(datPath, key)
} else {
imageDecryptLog('[ImageDecrypt] unsupported dat version:', version)
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
return null
}
@@ -356,7 +463,8 @@ export class ImageDecryptService {
}
/**
* 检测 DAT 文件版本
* 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
*/
private getDatVersion(inputPath: string): number {
const bytes = readFileSync(inputPath)
@@ -365,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
}
@@ -495,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)
@@ -504,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))
@@ -523,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)))
)
+319 -44
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,18 +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
@@ -80,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'
@@ -107,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,
@@ -130,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 {
@@ -139,9 +288,20 @@ 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',
@@ -169,24 +329,20 @@ app.whenReady().then(async () => {
})
})
// 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)
})
@@ -196,12 +352,24 @@ app.whenReady().then(async () => {
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()
@@ -216,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) {
@@ -297,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 })
@@ -364,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: [] }
}
)
@@ -391,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)
}
@@ -400,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))
@@ -443,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()
})
@@ -485,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) {
@@ -502,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) {
@@ -519,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
}
)
@@ -623,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', () => ({
@@ -637,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(),
@@ -665,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 }
})
@@ -750,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)
@@ -766,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') {
@@ -784,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'))
)
}
+3 -2
View File
@@ -15,6 +15,7 @@ import type {
import type { AppSettings } from './settings-store'
import { generateAgentGroupReport } from './agent-group-report-service'
import { AIProviderService } from './ai-provider-service'
import { isPackagedRuntime } from '../runtime-mode'
import {
getGroupSnapshot,
isReady,
@@ -68,7 +69,7 @@ const agentAIProvider = new AIProviderService()
function resolveBundledBinary(
resourceSegments: string[],
executable: string,
packaged = app.isPackaged,
packaged = isPackagedRuntime(),
platform = process.platform,
arch = process.arch
): string {
@@ -80,7 +81,7 @@ function resolveBundledBinary(
}
export function resolveWechatConnectorBinaryPath(
packaged = app.isPackaged,
packaged = isPackagedRuntime(),
platform = process.platform,
arch = process.arch
): string {
-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 {
@@ -338,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()
+120 -4
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,12 @@ 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>()
@@ -73,7 +87,9 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
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
@@ -122,7 +138,8 @@ function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {}
messages: {},
groupSnapshots: {}
}
memoryCache.set(getCacheFile(normalizedRoot), created)
return created
@@ -132,6 +149,33 @@ function messageBucketKey(userMd5: string, startTime?: number, endTime?: number)
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
@@ -260,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 -7
View File
@@ -2,7 +2,22 @@ import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { discoverWindowsDbRoots } from '../windows-db-root-discovery'
/**
* 把 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
@@ -13,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 {
@@ -25,21 +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')
]
candidates.push(...discoverWindowsDbRoots())
return unique(candidates)
}
@@ -89,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(
@@ -123,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 -52
View File
@@ -1,7 +1,6 @@
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { discoverWindowsDbRoots } from './windows-db-root-discovery'
import crypto from 'crypto'
import { createRequire } from 'module'
import { createConnection, Socket } from 'net'
@@ -11,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
@@ -30,6 +32,11 @@ export interface Wcdb4MessageQueryOptions {
limit?: number
}
type Wcdb4MessageStore = {
tableName: string
dbPath: string
}
export interface Wcdb4GroupMember {
m_nsUsrName: string
nickname: string
@@ -45,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
@@ -56,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
@@ -129,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]
@@ -158,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
@@ -217,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
@@ -297,17 +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')
]
candidates.push(...discoverWindowsDbRoots())
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[] {
@@ -331,7 +423,9 @@ export class Wcdb4Client {
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
}
@@ -411,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
@@ -588,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 =
@@ -616,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)
@@ -648,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 : []
@@ -678,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) =>
@@ -694,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 []
@@ -739,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)
@@ -856,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 {
@@ -915,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',
@@ -933,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,
@@ -966,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
@@ -994,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,
@@ -1073,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}`)
@@ -1164,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)'
@@ -1286,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)'
@@ -1448,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
@@ -1474,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 {
@@ -1520,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',
@@ -1541,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),
@@ -1584,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
@@ -1618,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
+2 -1
View File
@@ -1,7 +1,8 @@
import fs from 'fs-extra'
import path from 'path'
const DB_ROOT_NAMES = new Set(['xwechat_files', 'wechat files'])
// 仅支持 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',
+91 -2
View File
@@ -39,15 +39,45 @@ import type {
} 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
@@ -75,6 +105,13 @@ declare global {
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 }>
@@ -83,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: (
@@ -90,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,
@@ -143,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
@@ -151,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: (
@@ -191,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
}
@@ -217,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
}
@@ -231,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
}>
@@ -243,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
}
+30 -1
View File
@@ -17,18 +17,37 @@ import type {
} 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,
@@ -57,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'),
+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>
+483 -138
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState } from 'react'
import { Sidebar } from './components/Sidebar'
import ChatWindow from './components/ChatWindow'
import { AppShell } from './components/layout/AppShell'
@@ -20,6 +20,9 @@ import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportG
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types'
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
import { ExportWorkspace } from './components/export/ExportWorkspace'
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -38,13 +41,77 @@ interface SelfInfo {
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
const VIEW_MESSAGE_LIMIT = 600
const INITIAL_MESSAGE_COUNT = 20
const MESSAGE_PAGE_SIZE = 100
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
const EXPORT_PREVIEW_LIMIT = 20
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
const normalizeQuotedText = (value: string | undefined): string =>
String(value || '')
.replace(/\s+/g, ' ')
.trim()
const isInternalReferenceSender = (value: string | undefined): boolean => {
const sender = String(value || '').trim()
return (
!sender ||
sender.endsWith('@chatroom') ||
sender.startsWith('wxid_') ||
/^[a-z0-9_@.-]{12,}$/i.test(sender)
)
}
const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => {
const imageDatNameByMd5 = new Map<string, string>()
const messagesByContent = new Map<string, Message[]>()
for (const message of referenceMessages) {
if (
message.contentData?.type === 'image' &&
message.contentData.md5 &&
message.contentData.datName
) {
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
}
const content = normalizeQuotedText(message.content)
if (!content) continue
const candidates = messagesByContent.get(content) || []
candidates.push(message)
messagesByContent.set(content, candidates)
}
return messages.map((message) => {
if (message.contentData?.type !== 'quote') return message
const quote = message.contentData
let quotedImageDatName = quote.quotedImageDatName
if (!quotedImageDatName && quote.quotedImageMd5) {
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
}
let quotedSender = quote.quotedSender
if (isInternalReferenceSender(quotedSender)) {
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
const source = candidates
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
}
if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) {
return message
}
return {
...message,
contentData: { ...quote, quotedSender, quotedImageDatName }
}
})
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
@@ -76,7 +143,7 @@ type StartupProgress = {
}
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
member.nickname || member.wxid
member.groupNickname || member.nickname || member.remark || member.wechatNickname || member.wxid
const buildSyntheticGroupMessages = (
previous: GroupSnapshot | null,
@@ -132,6 +199,12 @@ const sortMessagesChronologically = (items: Message[]): Message[] =>
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
})
const mergeMessagePages = (older: Message[], current: Message[]): Message[] => {
const merged = new Map<string, Message>()
for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message)
return sortMessagesChronologically(Array.from(merged.values()))
}
function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
@@ -141,7 +214,6 @@ function App(): React.ReactElement {
const [messages, setMessages] = useState<Message[]>([])
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
const [dateRange, setDateRange] = useState('today') // 默认今天
const [contentFilter, setContentFilter] = useState('')
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [dbKeyStatus, setDbKeyStatus] = useState('')
@@ -153,6 +225,7 @@ function App(): React.ReactElement {
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
)
const [activePage, setActivePage] = useState<AppPage>('archive')
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
@@ -172,17 +245,49 @@ function App(): React.ReactElement {
})
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]')
return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : []
} catch {
return []
}
})
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
const [appearanceSettings, setAppearanceSettings] = React.useState<{
theme: 'system' | 'light' | 'dark'
compactMode: boolean
showStartupProgress: boolean
}>({ theme: 'system', compactMode: false, showStartupProgress: true })
const handleAppearanceChange = React.useCallback(
(settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => {
setAppearanceSettings((current) => ({ ...current, ...settings }))
},
[]
)
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
const messageHistoryRef = React.useRef<Message[]>([])
const messagesRef = React.useRef<Message[]>([])
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
messagesRef.current = messages
React.useEffect(() => {
if (!reportNotice) return
const timer = window.setTimeout(() => setReportNotice(''), 3200)
return () => window.clearTimeout(timer)
}, [reportNotice])
React.useEffect(() => {
void window.api.getSettings().then((result) => {
setAppearanceSettings({
theme: result.settings.appearanceTheme,
compactMode: result.settings.compactMode,
showStartupProgress: result.settings.showStartupProgress
})
})
}, [])
React.useEffect(() => {
const loadAIConfig = async (): Promise<void> => {
try {
@@ -216,6 +321,84 @@ function App(): React.ReactElement {
})
const lastCapturedReportKeyRef = React.useRef('')
React.useEffect(() => {
const unsubscribe = window.api.onExportProgress((progress: ExportJobProgress) => {
setExportTasks((current) =>
current.map((task) => {
if (task.jobId !== progress.jobId) return task
const status =
progress.phase === 'completed'
? 'completed'
: progress.phase === 'cancelled'
? 'cancelled'
: progress.phase === 'failed'
? 'failed'
: 'running'
return { ...task, status, progress }
})
)
})
return unsubscribe
}, [])
React.useEffect(() => {
localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20)))
}, [exportTasks])
const handleStartExport = async (
request: ExportRequest
): Promise<import('../../shared/export').ExportResult> => {
const task: ExportTaskRecord = {
jobId: request.jobId,
contactId: request.userMd5,
contactName: request.name,
format: request.format,
status: 'running',
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
createdAt: Date.now()
}
setExportTasks((current) =>
[task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20)
)
const result = await window.api.startExport(request)
setExportTasks((current) =>
current.map((item) =>
item.jobId === request.jobId
? result.success
? {
...item,
status: 'completed',
progress: {
...item.progress,
phase: 'completed',
processed: result.messageCount ?? item.progress.processed,
total: result.messageCount ?? item.progress.total,
percent: 100,
outputPath: result.outputPath
}
}
: {
...item,
status: 'failed',
progress: { ...item.progress, phase: 'failed', error: result.error }
}
: item
)
)
return result
}
const handleCancelExport = async (jobId: string): Promise<void> => {
await window.api.cancelExport(jobId)
setExportTasks((current) =>
current.map((task) =>
task.jobId === jobId
? { ...task, status: 'cancelled', progress: { ...task.progress, phase: 'cancelled' } }
: task
)
)
}
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
try {
const result = await window.api.listGeneratedReports()
@@ -316,7 +499,7 @@ function App(): React.ReactElement {
usernames.length ? 55 : 90
)
let loadedCount = 0
const chunkSize = 32
const chunkSize = 128
for (let index = 0; index < usernames.length; index += chunkSize) {
if (runId !== contactAvatarHydrationRunRef.current) return
const chunk = usernames.slice(index, index + chunkSize)
@@ -347,7 +530,7 @@ function App(): React.ReactElement {
} catch (error) {
console.warn('[Contacts] avatar hydrate failed:', error)
}
await new Promise((resolve) => window.setTimeout(resolve, 50))
await new Promise((resolve) => window.setTimeout(resolve, 20))
}
if (usernames.length) onProgress?.('头像加载完成', 90)
}
@@ -395,7 +578,34 @@ function App(): React.ReactElement {
}
if (!autoLoginEnabled) return
try {
const result = await window.api.initDb(key)
const startupCacheReady = await loadStartupCache()
const initPromise = window.api.initDb(key)
if (startupCacheReady) {
setIsAuthenticated(true)
setIsDatabaseConnected(false)
setBootState('login')
void initPromise
.then(async (result) => {
const success = typeof result === 'boolean' ? result : result.success
if (!success) {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`)
setDbKeyStatusKind('error')
return
}
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsDatabaseConnected(true)
setDbKeyStatus('已连接数据库')
// Cached contacts/self info are enough for startup. Native refresh is
// intentionally user-triggered so it cannot freeze the first session.
})
.catch((error) => {
console.warn('[Startup] background database init failed:', error)
setDbKeyStatusKind('error')
})
return
}
const result = await initPromise
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
@@ -406,9 +616,13 @@ function App(): React.ReactElement {
setIsDatabaseConnected(true)
setDbKeyStatus('已自动连接')
setDbKeyStatusKind('success')
await loadContacts()
await refreshSelfInfo(3)
setIsAuthenticated(true)
const hasBootstrap = await loadBootstrapCache()
if (hasBootstrap) {
setIsAuthenticated(true)
} else {
await Promise.all([loadContacts(), refreshSelfInfo(3)])
setIsAuthenticated(true)
}
} else {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
@@ -478,7 +692,7 @@ function App(): React.ReactElement {
detail: '正在读取本地缓存',
percent: 25
})
await loadBootstrapCache()
const hasBootstrap = await loadBootstrapCache()
// 持久化手动输入的密钥,供下次启动继续使用
void window.api.saveDbKey(keyToUse).catch(() => undefined)
void window.api.getSettings().then((current) => {
@@ -494,15 +708,24 @@ function App(): React.ReactElement {
})
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
await loadContacts({ waitForAvatars: false })
await refreshSelfInfo(3)
if (hasBootstrap) {
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
setIsAuthenticated(true)
void Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)]).catch(
(error) => {
console.warn('[Startup] background refresh failed:', error)
}
)
} else {
await Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)])
setIsAuthenticated(true)
}
setStartupProgress({
title: '加载完成',
subtitle: '正在进入主页面',
detail: '联系人和头像已准备好',
percent: 100
})
setIsAuthenticated(true)
setIsDatabaseConnected(true)
setBootState('login')
window.setTimeout(() => {
@@ -555,10 +778,14 @@ function App(): React.ReactElement {
const applyGroupMemberMeta = React.useCallback(
(contact: Contact | null, baseMessages: Message[]): Message[] => {
if (!contact || contact.type !== 'group') return baseMessages
const enrichedMessages = enrichQuotedMessages(baseMessages, [
...messageHistoryRef.current,
...baseMessages
])
const memberMap = groupMemberMetaRef.current[contact.md5]
if (!memberMap || memberMap.size === 0) return baseMessages
if (!memberMap || memberMap.size === 0) return enrichedMessages
return baseMessages.map((message) => {
return enrichedMessages.map((message) => {
const senderId = String(message.senderId || message.name || '').trim()
if (!senderId) return message
const member = memberMap.get(senderId)
@@ -581,23 +808,57 @@ function App(): React.ReactElement {
[]
)
const storeGroupMemberMeta = React.useCallback(
(contact: Contact, snapshot: GroupSnapshot): void => {
currentGroupSnapshotRef.current = snapshot
groupMemberMetaRef.current[contact.md5] = new Map(
snapshot.members.map((member) => [
member.wxid,
{ nickname: formatGroupMemberName(member), avatar: member.avatar || '' }
])
)
},
[]
)
const loadGroupMemberMeta = React.useCallback(
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
if (!contact || contact.type !== 'group') return null
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
if (!snapshot) return null
currentGroupSnapshotRef.current = snapshot
groupMemberMetaRef.current[contact.md5] = new Map(
snapshot.members.map((member) => [
member.wxid,
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
])
)
storeGroupMemberMeta(contact, snapshot)
return snapshot
},
[logGroupSnapshot]
[logGroupSnapshot, storeGroupMemberMeta]
)
const handleReloadCurrentAvatars = React.useCallback(async (): Promise<void> => {
const contact = selectedContact
if (!contact) return
try {
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
const avatar = avatars[contact.m_nsUsrName]
if (avatar) {
const updateContact = (item: Contact): Contact =>
item.md5 === contact.md5 ? { ...item, avatar } : item
setContacts((current) => current.map(updateContact))
setFilteredContacts((current) => current.map(updateContact))
setSelectedContact((current) =>
current?.md5 === contact.md5 ? updateContact(current) : current
)
}
} catch (error) {
console.warn('[Contacts] current avatar reload failed:', error)
}
const snapshot = await loadGroupMemberMeta(contact)
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
setMessages((current) =>
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
)
}, [applyGroupMemberMeta, loadGroupMemberMeta, mergeSyntheticMessages, selectedContact])
const handleAutoGetDbKey = async (): Promise<void> => {
if (isFetchingDbKey) return
setIsFetchingDbKey(true)
@@ -670,65 +931,57 @@ function App(): React.ReactElement {
setStartupProgress(null)
}
const getDateRangeParams = (
range: string
): { startTime: number | undefined; endTime: number | undefined } => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
let startTime: number | undefined
let endTime: number | undefined
switch (range) {
case 'today':
startTime = startOfToday
break
case 'yesterday':
startTime = startOfToday - 86400
endTime = startOfToday - 1 // 昨天结束
break
case '7':
startTime = Math.floor(Date.now() / 1000) - 7 * 86400
break
case '30':
startTime = Math.floor(Date.now() / 1000) - 30 * 86400
break
case 'all':
startTime = undefined
break
default:
startTime = startOfToday
}
return { startTime, endTime }
}
const handleSelectContact = async (contact: Contact): Promise<void> => {
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
setArchiveJumpTime(null)
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
currentGroupSnapshotRef.current = null
setIsMessagesLoading(true)
const { startTime, endTime } = getDateRangeParams(dateRange)
const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime)
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
const cachedMsgs = cachedPage.messages
if (selectedContactMd5Ref.current !== contact.md5) return
if (cachedMsgs.length) {
setMessages(
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, cachedMsgs.slice(-VIEW_MESSAGE_LIMIT))
)
)
} else {
setMessages([])
if (cachedPage.groupSnapshot) {
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
}
messageHistoryRef.current = cachedMsgs
setMessages(
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, cachedMsgs.slice(-INITIAL_MESSAGE_COUNT))
)
)
const needsLivePage = forceLive || isDatabaseConnected
if (!needsLivePage) {
setIsMessagesLoading(false)
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
window.setTimeout(() => {
void loadGroupMemberMeta(contact).then((snapshot) => {
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
setMessages((current) =>
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, current, snapshot.roomId)
)
)
})
}, 0)
}
return
}
if (cachedMsgs.length >= INITIAL_MESSAGE_COUNT) setIsMessagesLoading(false)
await waitForPaint()
try {
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
limit: VIEW_MESSAGE_LIMIT
const loadLivePage = async (): Promise<void> => {
const msgs = await window.api.getMessages(contact.md5, undefined, undefined, {
limit: MESSAGE_PREFETCH_COUNT
})
if (selectedContactMd5Ref.current !== contact.md5) return
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
setMessages(cachedMessages)
if (contact.type === 'group') {
messageHistoryRef.current = msgs
const visibleMessages = applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
)
setMessages(visibleMessages)
if (contact.type === 'group' && visibleMessages.length > 0) {
window.setTimeout(() => {
void loadGroupMemberMeta(contact).then((snapshot) => {
if (selectedContactMd5Ref.current !== contact.md5) return
@@ -742,42 +995,120 @@ function App(): React.ReactElement {
})
}, 120)
}
}
const prefetchPromise = loadLivePage()
messagePrefetchRef.current = prefetchPromise
try {
await prefetchPromise
} finally {
if (messagePrefetchRef.current === prefetchPromise) messagePrefetchRef.current = null
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
}
}
const handleOpenSearchEvidence = async (contact: Contact, createTime?: number): Promise<void> => {
setActivePage('archive')
await handleSelectContact(contact)
if (!createTime || selectedContactMd5Ref.current !== contact.md5) return
try {
const windowStart = Math.max(0, createTime - 12 * 3600)
const windowEnd = createTime + 12 * 3600
const nearbyMessages = await window.api.getMessages(contact.md5, windowStart, windowEnd)
if (selectedContactMd5Ref.current !== contact.md5) return
const focusedMessages = sortMessagesChronologically(nearbyMessages)
messageHistoryRef.current = focusedMessages
setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages)))
setArchiveJumpTime(createTime)
} catch (error) {
console.warn('[Search] evidence context load failed:', error)
setReportNotice('证据所在时间段加载失败,请在档案中手动查看')
}
}
React.useEffect(() => {
if (!isDatabaseConnected || !selectedContact) return
void handleSelectContact(selectedContact)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDatabaseConnected])
const loadStartupCache = async (): Promise<boolean> => {
try {
const cache = await window.api.getStartupCache()
if (!cache?.contacts.length) return false
setContacts(cache.contacts)
setFilteredContacts(cache.contacts)
if (cache.self) setSelfInfo(cache.self)
return true
} catch (error) {
console.warn('[StartupCache] load failed:', error)
return false
}
}
const handleLoadOlderMessages = async (): Promise<void> => {
const contact = selectedContact
if (!contact || messagesRef.current.length === 0) return
if (messagePrefetchRef.current) await messagePrefetchRef.current
if (selectedContactMd5Ref.current !== contact.md5) return
const currentMessages = messagesRef.current
const historyMessages = messageHistoryRef.current
const firstVisibleIndex = historyMessages.findIndex(
(message) => getMessageIdentity(message) === getMessageIdentity(currentMessages[0])
)
if (firstVisibleIndex > 0) {
const inMemoryOlder = historyMessages.slice(
Math.max(0, firstVisibleIndex - MESSAGE_PAGE_SIZE),
firstVisibleIndex
)
setMessages((current) =>
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, mergeMessagePages(inMemoryOlder, current))
)
)
return
}
const oldestTime = currentMessages[0]?.createTime
if (!oldestTime) return
setIsMessagesLoading(true)
try {
const cachedPage = await window.api.getCachedMessagePage(
contact.md5,
undefined,
oldestTime - 1
)
const olderMessages = cachedPage.hit
? cachedPage.messages
: await window.api.getMessages(contact.md5, undefined, oldestTime - 1, {
limit: MESSAGE_PAGE_SIZE
})
if (selectedContactMd5Ref.current !== contact.md5) return
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
setMessages((current) =>
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current))
)
)
} catch (error) {
console.warn('[Messages] older page load failed:', error)
} finally {
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
}
}
const handleDateRangeChange = (range: string): void => {
setDateRange(range)
if (selectedContact) {
const { startTime, endTime } = getDateRangeParams(range)
window.api
.getCachedMessages(selectedContact.md5, startTime, endTime)
.then((cachedMessages) => {
if (!cachedMessages.length) return
setMessages(
applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-VIEW_MESSAGE_LIMIT))
)
)
})
setIsMessagesLoading(true)
window.api
.getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT })
.then((nextMessages) => {
setMessages(
applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, nextMessages)
)
)
setIsMessagesLoading(false)
})
.catch((error) => {
console.warn('[Messages] date range load failed:', error)
setIsMessagesLoading(false)
})
const loadExportPreview = async (contact: Contact): Promise<void> => {
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
try {
const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, {
limit: EXPORT_PREVIEW_LIMIT
})
if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages)
} catch (error) {
console.warn('[Export] preview load failed:', error)
}
}
@@ -797,13 +1128,9 @@ function App(): React.ReactElement {
}
refreshInFlight = true
try {
const range = getDateRangeParams(dateRange)
const latestMessages = await window.api.getMessages(
contactMd5,
range.startTime,
range.endTime,
{ limit: VIEW_MESSAGE_LIMIT }
)
const latestMessages = await window.api.getMessages(contactMd5, undefined, undefined, {
limit: INITIAL_MESSAGE_COUNT
})
const nextMessages = applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, latestMessages)
@@ -827,7 +1154,12 @@ function App(): React.ReactElement {
}
}
const unsubscribe = window.api.onWcdbChange(() => {
const unsubscribe = window.api.onWcdbChange(({ json }) => {
const eventText = String(json || '').toLowerCase()
const targetIds = [contactMd5, selectedContact.m_nsUsrName]
.filter(Boolean)
.map((value) => value.toLowerCase())
if (!targetIds.some((targetId) => eventText.includes(targetId))) return
if (refreshTimer) window.clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
refreshTimer = null
@@ -841,7 +1173,6 @@ function App(): React.ReactElement {
unsubscribe()
}
}, [
dateRange,
isAuthenticated,
isNativeMonitorActive,
selectedContact,
@@ -869,6 +1200,8 @@ function App(): React.ReactElement {
const handlePageChange = (page: AppPage): void => {
setActivePage(page)
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
if (page === 'export' && selectedContact) void loadExportPreview(selectedContact)
if (page === 'settings') setSettingsCategory('account-database')
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
setReportSourceContact(selectedContact)
@@ -1038,24 +1371,6 @@ function App(): React.ReactElement {
return { success: true }
}
const renderPlaceholderPage = (
page: Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>
): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>, string> = {
search: '检索',
export: '导出',
api: 'API',
settings: '设置'
}
return (
<div className="app-page-placeholder">
<div className="app-page-placeholder-eyebrow">WechatExplorer</div>
<h2>{labels[page]}</h2>
<p> UI </p>
</div>
)
}
const renderArchiveWorkspace = (): React.ReactElement => (
<div className="app-container">
<Sidebar
@@ -1065,8 +1380,6 @@ function App(): React.ReactElement {
onSearch={handleSearchContacts}
onContentFilter={setContentFilter}
width={sidebarWidth}
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onOpenSettings={openSettings}
@@ -1078,12 +1391,14 @@ function App(): React.ReactElement {
messages={messages}
isLoadingMessages={isMessagesLoading}
contentFilter={contentFilter}
dateRange={dateRange}
onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
onRefreshData={loadContacts}
onReloadAvatars={handleReloadCurrentAvatars}
onLoadOlderMessages={handleLoadOlderMessages}
onCreateGroupReport={handleOpenReportWorkspace}
isAiLoading={reportGeneration.isGenerating}
jumpToTime={archiveJumpTime}
/>
</div>
)
@@ -1198,11 +1513,39 @@ function App(): React.ReactElement {
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
onNotice={setReportNotice}
onOpenSettings={openSettings}
onAppearanceChange={handleAppearanceChange}
/>
)
case 'search':
return (
<AISearchWorkspace
contacts={contacts}
selectedContact={selectedContact}
dbReady={isDatabaseConnected}
aiModelConfig={aiModelConfig}
onSelectContact={(contact) => void handleSelectContact(contact)}
onOpenEvidence={(contact, createTime) =>
void handleOpenSearchEvidence(contact, createTime)
}
onOpenAISettings={openModelSettings}
onNotice={setReportNotice}
/>
)
case 'export':
return renderPlaceholderPage(activePage)
return (
<ExportWorkspace
contacts={contacts}
selectedContact={selectedContact}
previewMessages={messages}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onSelectContact={loadExportPreview}
onOpenSettings={openSettings}
exportTasks={exportTasks}
onStartExport={handleStartExport}
onCancelExport={handleCancelExport}
/>
)
}
}
@@ -1257,7 +1600,7 @@ function App(): React.ReactElement {
: '使用上次安全保存的密钥'
: 'WechatExplorer')
return (
<div className="boot-splash">
<div className={`boot-splash ${appearanceSettings.showStartupProgress ? '' : 'is-quiet'}`}>
<div className="boot-splash-spinner" aria-hidden />
<div className="boot-splash-title">{title}</div>
<div className="boot-splash-subtitle">{subtitle}</div>
@@ -1308,6 +1651,8 @@ function App(): React.ReactElement {
dbReady={isDatabaseConnected}
onPageChange={handlePageChange}
onOpenSettings={openSettings}
appearanceTheme={appearanceSettings.theme}
compactMode={appearanceSettings.compactMode}
>
{reportNotice && <div className="app-toast">{reportNotice}</div>}
{renderCurrentWorkspace()}
File diff suppressed because it is too large Load Diff
+49 -108
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,50 +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 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> = ({
@@ -63,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')
@@ -85,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' })
@@ -94,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)
@@ -153,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
@@ -171,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 || '')
@@ -243,23 +185,18 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}, [messages, contentFilter])
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} />
@@ -273,13 +210,17 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
listRef={messageListRef}
bottomRef={messagesEndRef}
onScroll={handleMessageListScroll}
onReachTop={onLoadOlderMessages}
onImageClick={openImagePreview}
jumpToTime={jumpToTime}
/>
<ChatStatusBar
count={filteredMessages.length}
showAvatar={showAvatar}
isAtLatest={isAtLatest}
isReloadingAvatars={isReloadingAvatars}
onShowAvatarChange={setShowAvatar}
onReloadAvatars={() => void handleReloadAvatars()}
onJumpToLatest={scrollToBottom}
/>
+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>
@@ -14,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({
@@ -27,11 +29,14 @@ export function MessageList({
listRef,
bottomRef,
onScroll,
onImageClick
onReachTop,
onImageClick,
jumpToTime
}: MessageListProps): React.ReactElement {
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
// TanStack Virtual intentionally exposes mutable measurement methods.
// eslint-disable-next-line react-hooks/incompatible-library
const groupsRef = React.useRef(groups)
const loadingOlderRef = React.useRef(false)
groupsRef.current = groups
const virtualizer = useVirtualizer({
count: groups.length,
getScrollElement: () => listRef.current,
@@ -40,9 +45,79 @@ export function MessageList({
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">
@@ -60,7 +135,7 @@ export function MessageList({
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="virtual-message-group"
className={`virtual-message-group ${jumpTarget?.groupIndex === virtualItem.index ? 'archive-jump-target-group' : ''}`}
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<MessageGroup
@@ -69,6 +144,7 @@ export function MessageList({
isGroupChat={isGroupChat}
showAvatar={showAvatar}
onImageClick={onImageClick}
jumpTargetMessageId={jumpTarget?.messageId}
/>
</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} />
@@ -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.
}
}
@@ -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>
)
@@ -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>
)
}
+1 -2
View File
@@ -1,8 +1,7 @@
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
+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;
}
}
}
File diff suppressed because it is too large Load Diff
+523
View File
@@ -0,0 +1,523 @@
.conversation-sidebar {
box-sizing: border-box;
display: flex;
height: 100%;
min-height: 0;
flex: 0 0 auto;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
color: var(--wxex-text-primary);
}
.conversation-sidebar-header {
box-sizing: border-box;
flex: 0 0 auto;
padding: 16px 14px 12px;
border-bottom: 1px solid var(--wxex-border);
-webkit-app-region: drag;
}
.conversation-sidebar-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
h2 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 18px;
font-weight: 700;
line-height: 24px;
}
span {
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
}
.conversation-search {
box-sizing: border-box;
display: flex;
height: 34px;
align-items: center;
gap: 8px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
padding: 0 10px;
color: var(--wxex-text-muted);
-webkit-app-region: no-drag;
&:focus-within {
border-color: rgba(36, 122, 99, 0.58);
color: var(--wxex-brand);
}
input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
&::placeholder {
color: var(--wxex-text-muted);
}
}
}
.conversation-search-icon {
width: 17px;
height: 17px;
display: grid;
place-items: center;
flex: 0 0 auto;
}
.conversation-search-icon svg {
width: 17px;
height: 17px;
}
.conversation-search-icon circle,
.conversation-search-icon path {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.conversation-date-range {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
margin-top: 10px;
-webkit-app-region: no-drag;
}
.conversation-date-range-button {
box-sizing: border-box;
height: 30px;
min-width: 0;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
cursor: pointer;
font: 12px/16px var(--wxex-font);
white-space: nowrap;
&:hover {
border-color: rgba(36, 122, 99, 0.28);
color: var(--wxex-text-primary);
}
&.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #ffffff;
font-weight: 600;
}
}
.conversation-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 8px 12px;
}
.conversation-virtual-content {
position: relative;
width: 100%;
}
.conversation-virtual-row {
position: absolute;
top: 0;
right: 0;
left: 0;
}
.conversation-section + .conversation-section {
margin-top: 6px;
}
.conversation-section-header {
box-sizing: border-box;
width: 100%;
height: 32px;
display: flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/16px var(--wxex-font);
padding: 0 8px;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.48);
color: var(--wxex-text-primary);
}
}
.conversation-section-chevron {
width: 16px;
height: 16px;
display: grid;
place-items: center;
flex: 0 0 auto;
svg {
width: 14px;
height: 14px;
}
path {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.conversation-section-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-section-list {
position: relative;
overflow: visible;
}
.export-config-scroll > .export-section:not(.export-format-top):has(.export-format-grid) {
display: none;
}
.conversation-section-virtual-content {
position: relative;
width: 100%;
}
.conversation-section-virtual-row {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 58px;
}
.conversation-section-empty {
padding: 10px 12px 12px 28px;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
}
.conversation-item {
box-sizing: border-box;
position: relative;
width: 100%;
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-primary);
cursor: pointer;
font-family: var(--wxex-font);
padding: 8px 10px 8px 13px;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.56);
}
&.active {
background: var(--wxex-brand-soft);
.conversation-item-active-mark {
background: var(--wxex-brand);
}
}
}
.conversation-item-active-mark {
position: absolute;
top: 10px;
bottom: 10px;
left: 0;
width: 3px;
border-radius: 0 999px 999px 0;
background: transparent;
}
.conversation-item-avatar {
box-sizing: border-box;
width: 40px;
height: 40px;
display: grid;
place-items: center;
flex: 0 0 auto;
overflow: hidden;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
font-size: 14px;
font-weight: 700;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
}
.conversation-item-body {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
}
.conversation-item-name {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-item-meta {
overflow: hidden;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-sidebar-account {
box-sizing: border-box;
flex: 0 0 auto;
border-top: 1px solid var(--wxex-border);
padding: 10px;
}
.conversation-sidebar-account .account-summary {
min-height: 58px;
padding: 8px;
}
.conversation-sidebar-account .account-summary-text {
flex: 1;
}
.sidebar {
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
flex-shrink: 0;
/* Prevent shrinking */
}
.sidebar-header {
flex: 0 0 auto;
padding: 10px;
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag;
}
.sidebar-footer {
padding: 10px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
}
.sidebar-btn {
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
color: #666;
&:hover {
color: #333;
}
}
.sidebar-status {
color: #07c160;
font-size: 12px;
}
.search-input {
width: 100%;
padding: 5px;
border-radius: 4px;
border: 1px solid #ccc;
-webkit-app-region: no-drag;
}
.date-range-selector {
display: flex;
gap: 5px;
margin-top: 8px;
justify-content: space-between;
}
.range-btn {
padding: 2px 6px;
font-size: 10px;
border: 1px solid #ccc;
border-radius: 10px;
background: #fff;
cursor: pointer;
color: #666;
flex: 1;
text-align: center;
&.active {
background: #07c160;
color: #fff;
border-color: #07c160;
}
}
.contact-list {
flex: 1;
overflow-y: auto;
}
.section-header {
padding: 8px 10px;
background-color: #f0f0f0;
font-size: 12px;
color: #666;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
user-select: none;
&:hover {
background-color: #e0e0e0;
}
.arrow {
margin-right: 5px;
font-size: 10px;
width: 12px;
}
}
.section-empty {
padding: 10px 14px 12px 27px;
color: #888;
font-size: 12px;
}
.contact-item {
padding: 10px;
display: flex;
align-items: center;
cursor: pointer;
&:hover {
background-color: var(--sidebar-hover);
}
&.active {
background-color: var(--sidebar-active);
}
}
.contact-avatar {
width: 40px;
height: 40px;
border-radius: 4px;
background-color: #ccc;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #fff;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.message-avatar {
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.contact-info {
flex: 1;
overflow: hidden;
}
.contact-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.resizer {
width: 4px;
cursor: col-resize;
background-color: transparent;
border-right: 1px solid transparent;
transition:
background-color 0.2s,
border-color 0.2s;
z-index: 10;
}
.resizer:hover,
.resizer:active {
border-color: rgba(36, 122, 99, 0.22);
background-color: rgba(36, 122, 99, 0.06);
}
+951
View File
@@ -0,0 +1,951 @@
/* Export workspace */
.export-workspace {
display: grid;
grid-template-columns: 292px minmax(520px, 1fr) 360px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
background: var(--wxex-bg-main);
}
.export-contact-panel,
.export-config-panel,
.export-preview-panel {
min-width: 0;
min-height: 0;
background: var(--wxex-bg-elevated);
}
.export-contact-panel {
display: flex;
flex-direction: column;
border-right: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
}
.export-panel-header {
padding: 20px 16px 12px;
border-bottom: 1px solid var(--wxex-border);
}
.export-panel-title-row,
.export-section-heading,
.export-preview-heading,
.export-action-bar,
.export-target-path,
.export-media-master,
.export-account-summary {
display: flex;
align-items: center;
}
.export-panel-title-row {
justify-content: space-between;
gap: 8px;
margin-bottom: 14px;
h2 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 17px;
font-weight: 700;
}
}
.export-count-badge {
padding: 3px 8px;
border-radius: 5px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
font-size: 11px;
white-space: nowrap;
}
.export-search-field {
display: flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/20px var(--wxex-font);
}
}
.export-filter-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
margin-top: 12px;
padding: 3px;
border-radius: 7px;
background: #e3e9e5;
button {
border: 0;
border-radius: 5px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/30px var(--wxex-font);
&.active {
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.06);
}
}
}
.export-contact-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 0;
}
.export-contact-item {
display: flex;
align-items: center;
width: 100%;
gap: 10px;
padding: 11px 16px;
border: 0;
border-left: 3px solid transparent;
background: transparent;
color: var(--wxex-text-primary);
cursor: pointer;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.58);
}
&.active {
border-left-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
}
.export-contact-avatar,
.export-account-avatar,
.export-chat-avatar,
.export-preview-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
overflow: hidden;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-weight: 700;
}
.export-contact-avatar {
width: 38px;
height: 38px;
border-radius: 8px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.export-contact-copy,
.export-account-summary > span:last-child {
display: grid;
min-width: 0;
gap: 2px;
strong {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
color: var(--wxex-text-muted);
font-size: 11px;
}
}
.export-account-summary {
small.ready {
color: var(--wxex-success);
}
}
.export-account-summary {
gap: 9px;
padding: 14px 16px;
border: 0;
border-top: 1px solid var(--wxex-border);
background: transparent;
text-align: left;
cursor: pointer;
}
.export-account-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.export-config-panel {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wxex-bg-main);
}
.export-config-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 24px 28px 28px;
}
.export-config-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 26px;
h1 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
}
p {
margin: 2px 0 0;
color: var(--wxex-text-secondary);
font-size: 13px;
}
}
.export-chat-avatar {
width: 52px;
height: 52px;
border-radius: 10px;
font-size: 22px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.export-section {
margin-bottom: 25px;
h3 {
margin: 0 0 12px;
color: var(--wxex-text-primary);
font-size: 12px;
font-weight: 700;
}
}
.export-section-heading {
justify-content: space-between;
gap: 12px;
span {
color: var(--wxex-brand);
font-size: 12px;
}
}
.export-range-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
button {
border: 0;
border-radius: 5px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/30px var(--wxex-font);
border: 1px solid var(--wxex-border);
background: var(--wxex-bg-elevated);
line-height: 36px;
&.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
box-shadow: none;
}
}
}
.export-date-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
padding: 12px;
border-radius: 8px;
background: #f0f3f0;
label {
display: grid;
gap: 5px;
color: var(--wxex-text-secondary);
font-size: 11px;
}
input {
width: 100%;
min-width: 0;
padding: 8px 9px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: #fff;
color: var(--wxex-text-primary);
font: 13px/20px var(--wxex-font);
}
}
.export-kind-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px 20px;
padding: 14px;
border-radius: 8px;
background: #f0f3f0;
}
.export-check-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
color: var(--wxex-text-primary);
font-size: 12px;
cursor: pointer;
input {
width: 16px;
height: 16px;
accent-color: var(--wxex-brand);
}
&.unsupported {
color: var(--wxex-text-muted);
cursor: not-allowed;
input {
cursor: not-allowed;
}
}
}
.export-unsupported-hint {
display: inline-grid;
width: 16px;
height: 16px;
place-items: center;
border: 1px solid #c57923;
border-radius: 50%;
color: #a85e13;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.export-name-mode-grid {
display: flex;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: #f0f3f0;
}
.export-name-mode-option {
display: flex;
align-items: center;
gap: 7px;
flex: 1;
min-height: 32px;
padding: 0 8px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: #fff;
color: var(--wxex-text-primary);
font-size: 12px;
cursor: pointer;
input {
accent-color: var(--wxex-brand);
}
}
.export-media-master {
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: #fff;
color: var(--wxex-text-primary);
font-size: 12px;
input {
width: 17px;
height: 17px;
accent-color: var(--wxex-brand);
}
}
.export-media-options {
display: grid;
gap: 2px;
margin-top: 8px;
padding: 9px 13px;
border-radius: 8px;
background: #f0f3f0;
&.disabled {
opacity: 0.52;
}
}
.export-resource-statuses {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
span {
padding: 3px 7px;
border-radius: 4px;
background: var(--wxex-brand-soft);
color: var(--wxex-success);
font-size: 10px;
}
}
.export-helper-text {
margin: 9px 0 0;
color: var(--wxex-text-muted);
font-size: 11px;
}
.export-format-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
button {
min-height: 78px;
border: 1px solid var(--wxex-border);
border-radius: 8px;
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
&.active {
border: 2px solid var(--wxex-brand);
background: #f2f8f5;
}
}
strong {
display: block;
font-size: 13px;
}
small {
display: block;
margin-top: 6px;
color: var(--wxex-success);
font-size: 10px;
}
}
.export-html-options {
display: grid;
gap: 8px;
margin-top: 10px;
padding: 11px 13px;
border: 1px solid var(--wxex-border);
border-radius: 8px;
background: #f0f3f0;
color: var(--wxex-text-primary);
font-size: 12px;
input {
accent-color: var(--wxex-brand);
}
}
.export-save-section {
display: grid;
gap: 12px;
> label {
display: grid;
gap: 5px;
color: var(--wxex-text-secondary);
font-size: 11px;
input {
width: 100%;
min-width: 0;
padding: 8px 9px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
outline: 0;
background: #fff;
color: var(--wxex-text-primary);
font: 13px/20px var(--wxex-font);
}
}
}
.export-target-path {
gap: 10px;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: #fff;
color: var(--wxex-text-secondary);
font-size: 11px;
strong {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--wxex-text-primary);
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
padding: 5px 8px;
border: 1px solid var(--wxex-border);
border-radius: 5px;
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
font: 11px var(--wxex-font);
}
}
.export-action-bar {
flex: 0 0 58px;
gap: 8px;
padding: 0 22px;
border-top: 1px solid var(--wxex-border);
background: #fff;
color: var(--wxex-text-secondary);
font-size: 12px;
}
.export-ready-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--wxex-success);
&.completed {
background: var(--wxex-brand);
}
}
.export-target-summary {
flex: 1;
min-width: 0;
margin-left: 14px;
overflow: hidden;
color: var(--wxex-text-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.export-reset-button {
border: 0;
background: transparent;
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px var(--wxex-font);
}
.export-primary-button {
min-width: 132px;
padding: 10px 16px;
border: 0;
border-radius: 7px;
background: var(--wxex-brand);
color: #fff;
cursor: pointer;
font: 700 12px var(--wxex-font);
&:disabled {
cursor: default;
opacity: 0.55;
}
}
.export-preview-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-left: 1px solid var(--wxex-border);
background: #fbfcfb;
}
.export-preview-heading {
justify-content: space-between;
flex: 0 0 52px;
padding: 0 18px;
border-bottom: 1px solid var(--wxex-border);
color: var(--wxex-text-primary);
font-size: 12px;
span {
color: var(--wxex-text-muted);
font-size: 11px;
}
}
.export-message-preview {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 14px;
}
.export-preview-date {
width: fit-content;
margin: 0 auto 18px;
padding: 4px 9px;
border-radius: 10px;
background: #e9eeeb;
color: var(--wxex-text-secondary);
font-size: 10px;
}
.export-preview-message {
display: flex;
align-items: flex-start;
gap: 8px;
width: min(100%, 620px);
margin: 0 auto 15px;
&.mine {
flex-direction: row-reverse;
.export-preview-bubble {
background: #95ec69;
}
}
&.system {
justify-content: center;
flex-direction: row;
.export-preview-avatar {
display: none;
}
.export-preview-bubble {
max-width: 92%;
padding: 5px 10px;
border: 0;
border-radius: 5px;
background: #e9eeeb;
color: var(--wxex-text-muted);
font-size: 11px;
text-align: center;
box-shadow: none;
small {
display: none;
}
}
}
}
.export-preview-avatar {
width: 30px;
height: 30px;
border-radius: 7px;
font-size: 11px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.export-preview-bubble {
max-width: 78%;
padding: 9px 10px;
border-radius: 8px;
background: #fff;
color: var(--wxex-text-primary);
font-size: 12px;
line-height: 18px;
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.05);
small {
display: block;
margin-bottom: 3px;
color: var(--wxex-text-muted);
font-size: 10px;
}
}
.export-preview-stats {
flex: 0 0 auto;
display: grid;
gap: 12px;
padding: 18px;
border-top: 1px solid var(--wxex-border);
}
.export-task-center-button {
align-self: flex-start;
margin: 0 0 12px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 7px 11px;
font: 600 12px/18px var(--wxex-font);
}
.export-task-center {
margin: 0 0 18px;
padding: 12px;
border: 1px solid var(--wxex-border);
border-radius: 8px;
background: var(--wxex-bg-elevated);
}
.export-task-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
padding: 9px 0;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-secondary);
font-size: 12px;
&:first-of-type {
margin-top: 8px;
}
> span:first-child {
display: grid;
gap: 2px;
min-width: 0;
}
strong,
small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
color: var(--wxex-text-primary);
}
button {
border: 1px solid var(--wxex-border);
border-radius: 5px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
padding: 4px 8px;
}
}
.export-task-progress {
position: relative;
width: 110px;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--wxex-border);
i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--wxex-brand);
}
b {
position: absolute;
top: 10px;
right: 0;
color: var(--wxex-text-muted);
font-size: 10px;
font-weight: 500;
}
}
.export-preview-panel .export-preview-stats:not(.export-preview-real-stats) {
display: none;
}
.export-preview-stats span,
.export-complete-summary span {
display: flex;
justify-content: space-between;
color: var(--wxex-text-secondary);
font-size: 12px;
}
.export-preview-stats strong,
.export-complete-summary strong {
color: var(--wxex-text-primary);
font-weight: 600;
}
.export-job-state {
display: grid;
align-content: center;
gap: 12px;
height: 100%;
padding: 28px;
text-align: center;
h2 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 18px;
font-weight: 700;
}
p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
ol {
display: grid;
gap: 10px;
margin: 8px 0;
padding: 0;
list-style: none;
text-align: left;
}
li {
color: var(--wxex-text-muted);
font-size: 12px;
&::before {
display: inline-grid;
width: 16px;
height: 16px;
margin-right: 8px;
place-items: center;
border: 1px solid var(--wxex-border);
border-radius: 50%;
content: '';
vertical-align: -3px;
}
&.done {
color: var(--wxex-success);
&::before {
border-color: var(--wxex-success);
background: var(--wxex-success);
content: '';
color: #fff;
font-size: 10px;
}
}
&.current {
color: var(--wxex-text-primary);
font-weight: 600;
&::before {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
box-shadow: inset 0 0 0 4px #fff;
}
}
}
> strong {
color: var(--wxex-text-secondary);
font-size: 12px;
font-weight: 500;
}
}
.export-progress-bar {
height: 6px;
overflow: hidden;
border-radius: 4px;
background: #e2e9e4;
span {
display: block;
height: 100%;
border-radius: 4px;
background: var(--wxex-brand);
transition: width 0.2s ease;
}
}
.export-job-state > strong {
color: var(--wxex-text-secondary);
font-size: 12px;
font-weight: 500;
}
.export-cancel-button,
.export-open-folder-button {
padding: 10px 14px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px var(--wxex-font);
}
.export-success-icon {
width: 58px;
height: 58px;
margin: 0 auto 4px;
border: 5px solid var(--wxex-brand-soft);
border-radius: 50%;
background: #fff;
color: var(--wxex-brand);
font-size: 30px;
line-height: 48px;
}
.export-complete-summary {
display: grid;
gap: 12px;
margin: 12px 0;
padding: 14px;
border-radius: 8px;
background: #eef3ef;
text-align: left;
}
@media (max-width: 1100px) {
.export-workspace {
grid-template-columns: 248px minmax(460px, 1fr);
}
.export-preview-panel {
display: none;
}
}
+348
View File
@@ -0,0 +1,348 @@
:root {
--bg-color: #f5f5f5;
--sidebar-bg: #e7e7e7;
--sidebar-hover: #d6d6d6;
--sidebar-active: #c6c6c6;
--chat-bg: #f5f5f5;
--message-bg-user: #95ec69;
--message-bg-other: #ffffff;
--text-color: #000000;
--border-color: #dcdcdc;
}
body {
margin: 0;
padding: 0;
font-family: var(--wxex-font);
background-color: var(--wxex-bg-app);
color: var(--wxex-text-primary);
height: 100vh;
overflow: hidden;
}
#root {
height: 100%;
display: flex;
}
.app-shell {
box-sizing: border-box;
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--wxex-bg-app);
}
.app-primary-rail {
box-sizing: border-box;
width: var(--wxex-nav-width);
flex: 0 0 var(--wxex-nav-width);
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
padding: 12px 8px;
overflow: hidden;
-webkit-app-region: drag;
}
.app-brand {
box-sizing: border-box;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 0;
border-radius: 12px;
background: transparent;
-webkit-app-region: no-drag;
img {
display: block;
width: 42px;
height: 42px;
border-radius: 12px;
}
}
.primary-navigation {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
width: 100%;
margin-top: 18px;
-webkit-app-region: no-drag;
}
.primary-nav-item {
width: 100%;
height: 58px;
flex: 0 0 58px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font-family: var(--wxex-font);
&:hover {
background: rgba(255, 255, 255, 0.46);
color: var(--wxex-text-primary);
}
&.active {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
}
.primary-nav-mark {
width: 24px;
height: 24px;
display: grid;
place-items: center;
svg {
width: 22px;
height: 22px;
}
path,
circle {
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.primary-nav-label {
font-size: 12px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
}
.app-rail-account {
box-sizing: border-box;
width: 100%;
flex: 0 0 auto;
-webkit-app-region: no-drag;
}
.account-summary {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-primary);
font-family: var(--wxex-font);
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.72);
}
}
.account-summary.compact {
justify-content: center;
cursor: pointer;
padding: 8px 0;
}
.account-summary-avatar {
box-sizing: border-box;
position: relative;
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
font-size: 14px;
font-weight: 700;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.account-summary-status {
position: absolute;
right: 2px;
bottom: 2px;
width: 9px;
height: 9px;
border: 2px solid var(--wxex-bg-elevated);
border-radius: 999px;
background: var(--wxex-text-muted);
&.ready {
background: var(--wxex-success);
}
}
.account-summary:not(.compact) .account-summary-avatar .account-summary-status {
display: none;
}
.account-summary-text {
min-width: 0;
display: flex;
flex-direction: column;
}
.account-summary-name {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-summary-meta {
overflow: hidden;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-summary-state {
overflow: hidden;
display: flex;
align-items: center;
gap: 5px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-summary-state-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 999px;
background: var(--wxex-text-muted);
&.ready {
background: var(--wxex-success);
}
}
.account-summary-settings {
box-sizing: border-box;
width: 28px;
height: 28px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
&:hover {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
svg {
width: 17px;
height: 17px;
}
path {
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.app-shell-main {
box-sizing: border-box;
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
align-items: stretch;
overflow: hidden;
padding-top: var(--wxex-shell-content-top);
background: var(--wxex-bg-main);
}
.app-page-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--wxex-text-secondary);
text-align: center;
h2 {
margin: 0 0 8px;
color: var(--wxex-text-primary);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
p {
margin: 0;
font-size: 13px;
line-height: 20px;
}
}
.app-page-placeholder-eyebrow {
margin-bottom: 8px;
color: var(--wxex-brand);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.app-container {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
align-items: stretch;
overflow: hidden;
}
+18
View File
@@ -0,0 +1,18 @@
@use './_tokens';
@use './_base';
@use './foundation';
@use './conversation';
@use './chat';
@use './rich-message';
@use './shell-overlays';
@use './reports-core';
@use './settings';
@use './api';
@use './reports-templates';
@use './export';
@use './search';
@use './archive';
@use './settings-advanced';
@use './settings-preferences';
@use './theme';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,821 @@
@media (max-width: 1100px) {
.api-center-layout {
grid-template-columns: minmax(0, 1fr) 280px;
}
.api-main-scroll {
padding: 18px;
}
.api-header-actions {
max-width: 260px;
justify-content: flex-end;
}
.api-endpoint-head,
.api-endpoint-row {
grid-template-columns: 56px minmax(110px, 1fr) minmax(100px, 1fr) 78px;
gap: 7px;
padding: 10px;
}
.api-flow {
overflow-x: auto;
justify-content: flex-start;
}
}
.report-history-list-title {
padding: 4px 4px 8px;
color: var(--wxex-text-secondary);
font: 700 13px/18px var(--wxex-font);
}
.report-history-group {
margin: 0 0 10px;
}
.report-history-group h3 {
margin: 0;
padding: 6px 4px;
color: var(--wxex-text-muted);
font: 700 12px/16px var(--wxex-font);
}
.report-history-item {
position: relative;
display: flex;
width: 100%;
min-width: 0;
box-sizing: border-box;
gap: 10px;
padding: 10px 54px 10px 12px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-primary);
text-align: left;
cursor: pointer;
font-family: var(--wxex-font);
}
.report-history-item:hover {
background: rgba(255, 255, 255, 0.62);
}
.report-history-item.active {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.report-history-item.active::before {
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 4px;
background: var(--wxex-brand);
content: '';
}
.report-history-avatar {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
font: 700 14px/1 var(--wxex-font);
}
.report-history-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.report-history-text {
display: grid;
flex: 1 1 auto;
min-width: 0;
gap: 2px;
}
.report-history-text b,
.report-history-text small,
.report-history-text em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-history-text b {
color: inherit;
font: 600 13px/18px var(--wxex-font);
}
.report-history-text small,
.report-history-text em {
color: var(--wxex-text-muted);
font: normal 12px/16px var(--wxex-font);
}
.report-history-delete {
position: absolute;
right: 10px;
top: 50%;
display: grid;
width: 38px;
height: 26px;
place-items: center;
border: 1px solid rgba(198, 72, 72, 0.22);
border-radius: var(--wxex-radius-sm);
background: rgba(198, 72, 72, 0.06);
color: #a64242;
cursor: pointer;
font: 600 12px/16px var(--wxex-font);
opacity: 0;
transform: translateY(-50%);
transition: opacity 0.12s ease;
}
.report-history-item:hover .report-history-delete,
.report-history-delete:focus {
opacity: 1;
}
.report-history-empty {
display: grid;
gap: 10px;
padding: 18px 8px;
color: var(--wxex-text-muted);
font: 13px/18px var(--wxex-font);
}
.report-history-empty button,
.report-center-empty button {
justify-self: start;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--wxex-brand);
border-radius: var(--wxex-radius-md);
background: var(--wxex-brand);
color: #fff;
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
.report-history-account {
flex: 0 0 auto;
padding: 12px;
border-top: 1px solid var(--wxex-border);
}
.report-delete-confirm {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
background: rgba(32, 39, 36, 0.28);
}
.report-delete-confirm-card {
display: grid;
width: min(360px, calc(100vw - 48px));
gap: 12px;
padding: 18px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-main);
box-shadow: var(--wxex-shadow-popover);
}
.report-delete-confirm-card h2 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 18px/24px var(--wxex-font);
}
.report-delete-confirm-card p {
margin: 0;
color: var(--wxex-text-secondary);
font: 13px/20px var(--wxex-font);
}
.report-delete-confirm-card > div {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.report-delete-confirm-card button {
min-height: 32px;
padding: 0 14px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
.report-delete-confirm-card button.danger {
border-color: #a64242;
background: #a64242;
color: #fff;
}
.report-delete-error {
color: #a64242 !important;
}
.report-viewer {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
background: #f2f1ee;
}
.report-viewer-header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 22px 12px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
}
.report-viewer-header h1 {
font: 700 20px/27px var(--wxex-font);
}
.report-viewer-toolbar {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.report-viewer-toolbar button,
.report-zoom-bar button,
.report-more-popover button,
.report-settings-section button {
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
.report-viewer-toolbar button.primary {
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #fff;
}
.report-viewer-toolbar button:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.72;
}
.report-more-menu {
position: relative;
}
.report-more-popover {
position: absolute;
top: 38px;
right: 0;
z-index: 8;
min-width: 128px;
padding: 6px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: var(--wxex-shadow-popover);
}
.report-more-popover button {
width: 100%;
border: 0;
text-align: left;
}
.report-viewer-status {
flex: 0 0 auto;
padding: 8px 22px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-ai-soft);
color: var(--wxex-ai);
font: 12px/18px var(--wxex-font);
}
.report-viewer-stage {
display: block;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: auto;
padding: 22px;
}
.report-canvas {
width: max-content;
margin: 0 auto;
padding: 0;
background: #fff;
box-shadow: 0 8px 24px rgba(32, 39, 36, 0.14);
}
.report-canvas img {
display: block;
max-width: none;
height: auto;
}
.report-zoom-bar {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 48px;
border-top: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
}
.report-zoom-bar span {
min-width: 48px;
color: var(--wxex-text-secondary);
text-align: center;
font: 12px/18px var(--wxex-font);
}
.report-center-empty {
display: grid;
align-self: center;
justify-self: center;
max-width: 360px;
gap: 12px;
margin: auto;
color: var(--wxex-text-secondary);
text-align: center;
}
.report-center-empty-icon {
display: grid;
width: 54px;
height: 54px;
place-items: center;
justify-self: center;
border: 1px solid rgba(104, 110, 220, 0.18);
border-radius: 18px;
background: var(--wxex-ai-soft);
color: var(--wxex-ai);
}
.report-center-empty-icon svg {
width: 28px;
height: 28px;
}
.report-center-empty-icon path {
fill: currentColor;
}
.report-center-empty h2 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 18px/24px var(--wxex-font);
}
.report-center-empty p {
margin: 0;
font: 13px/20px var(--wxex-font);
}
.report-center-empty button {
justify-self: center;
}
.report-settings-panel {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow-y: auto;
border-left: 1px solid var(--wxex-border);
background: #f7f9f8;
}
.report-settings-panel header {
flex: 0 0 auto;
padding: 20px 18px 14px;
border-bottom: 1px solid var(--wxex-border);
}
.report-settings-panel header h2 {
font: 700 16px/22px var(--wxex-font);
}
.report-settings-section {
margin: 0;
padding: 16px 18px;
border-bottom: 1px solid var(--wxex-border);
}
.report-settings-section h3 {
margin: 0 0 8px;
color: var(--wxex-text-primary);
font: 700 14px/20px var(--wxex-font);
}
.report-settings-section p,
.report-settings-section code {
margin: 0 0 10px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-settings-section code {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
padding: 8px;
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
}
.report-settings-section.muted {
color: var(--wxex-text-muted);
}
.report-export-list {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.report-export-list div {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-export-list b {
min-width: 0;
color: var(--wxex-text-primary);
font-weight: 600;
overflow-wrap: anywhere;
text-align: right;
}
.report-generation-log {
display: grid;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.report-generation-log li {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 8px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-generation-log li > span {
color: var(--wxex-brand);
font-weight: 700;
}
.report-generation-log b,
.report-generation-log time,
.report-generation-log small {
display: block;
}
.report-generation-log b {
color: var(--wxex-text-primary);
font-weight: 600;
}
.report-generation-log time {
color: var(--wxex-text-muted);
font-size: 11px;
}
.report-generation-log small {
color: var(--wxex-text-secondary);
font: 600 11px/16px var(--wxex-font);
}
@media (max-width: 1120px) {
.report-center-page {
grid-template-columns: 268px minmax(360px, 1fr) 280px;
}
.report-viewer-header {
flex-direction: column;
}
}
/* ============================================================ */
/* 日报模板选择器 + 预览器 */
/* ============================================================ */
.report-section-desc {
margin: 0 0 12px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.report-template-list {
display: grid;
gap: 10px;
}
.report-template-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1.5px solid var(--wxex-border);
border-radius: 12px;
background: #fff;
transition:
border-color 0.2s,
background 0.2s;
}
.report-template-item.active {
border-color: var(--wxex-primary, #07c160);
background: #f0fbf3;
}
.report-template-item.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.report-template-item > label {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
min-width: 0;
}
.report-template-item input[type='radio'] {
accent-color: var(--wxex-primary, #07c160);
}
.report-template-body {
flex: 1;
min-width: 0;
}
.report-template-title {
font-size: 14px;
font-weight: 700;
color: var(--wxex-text-primary, #1f2933);
}
.report-template-tagline {
margin-top: 4px;
font-size: 12px;
color: var(--wxex-text-secondary, #485465);
line-height: 1.5;
}
.report-template-preview-btn {
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
border: 1px solid var(--wxex-border);
background: #fff;
color: var(--wxex-text-primary, #1f2933);
cursor: pointer;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
}
.report-template-preview-btn:hover {
background: var(--wxex-primary, #07c160);
color: #fff;
border-color: var(--wxex-primary, #07c160);
}
/* 预览遮罩 */
.report-template-preview-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
padding: 20px;
}
.report-template-preview-card {
background: #f3f5f7;
border-radius: 18px;
padding: 18px;
width: min(380px, 100%);
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25);
}
.report-template-preview-card h4 {
margin: 0 0 6px;
font-size: 17px;
font-weight: 800;
}
.report-template-preview-card p.muted {
margin: 0 0 14px;
font-size: 12px;
color: var(--wxex-text-secondary, #485465);
line-height: 1.5;
}
.report-template-preview-frame {
display: grid;
gap: 8px;
background: #fff;
border-radius: 14px;
padding: 12px;
border: 1px solid var(--wxex-border);
}
.fake-card {
background: #f7faf9;
border-radius: 10px;
padding: 10px 12px;
}
.fake-hero {
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
}
.fake-title {
font-size: 14px;
font-weight: 800;
color: #076c39;
}
.fake-sub {
margin-top: 2px;
font-size: 11px;
color: #485465;
}
.fake-section {
display: flex;
align-items: center;
gap: 8px;
}
.fake-bar {
width: 4px;
height: 14px;
border-radius: 2px;
background: var(--wxex-primary, #07c160);
}
.fake-section-title {
font-size: 12px;
font-weight: 700;
color: #1f2933;
}
.report-template-preview-close {
margin-top: 14px;
width: 100%;
padding: 10px;
border-radius: 10px;
border: none;
background: var(--wxex-primary, #07c160);
color: #fff;
font-weight: 700;
cursor: pointer;
}
.settings-auto-login-card {
padding: 18px 20px;
}
.settings-auto-login-card label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
cursor: pointer;
}
.settings-auto-login-card label > span {
display: grid;
gap: 4px;
}
.settings-auto-login-card b {
color: var(--wxex-text-primary);
font: 700 13px/19px var(--wxex-font);
}
.settings-auto-login-card small {
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.settings-auto-login-card input {
width: 18px;
height: 18px;
accent-color: var(--wxex-brand);
}
.settings-recall-card {
padding: 18px 20px;
}
.settings-recall-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr);
gap: 18px;
align-items: center;
}
.settings-recall-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-width: 0;
cursor: pointer;
}
.settings-recall-option span {
display: grid;
gap: 4px;
}
.settings-recall-option b {
color: var(--wxex-text-primary);
font: 700 13px/19px var(--wxex-font);
}
.settings-recall-option small {
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.settings-recall-option input {
width: 18px;
height: 18px;
flex: 0 0 auto;
accent-color: var(--wxex-brand);
}
.settings-recall-notice {
display: grid;
gap: 3px;
padding-left: 14px;
border-left: 2px solid #e5b45f;
color: #765326;
font: 12px/18px var(--wxex-font);
}
.settings-recall-notice strong {
font-weight: 700;
}
.settings-recall-notice span {
color: #8a6a3e;
}
.api-upload-test-row {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
}
.api-upload-test-row button {
min-height: 32px;
padding: 0 12px;
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-upload-test-row span {
color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font);
}
+331
View File
@@ -0,0 +1,331 @@
/* Voice Player */
.voice-message {
cursor: pointer;
user-select: none;
}
.voice-loading {
opacity: 0.6;
}
.voice-error {
opacity: 1;
}
.voice-loading-text {
font-size: 12px;
color: var(--wxex-text-muted);
}
.voice-error-text {
color: var(--wxex-text-secondary);
}
.voice-duration {
font-size: 12px;
margin-left: 4px;
min-width: 32px;
}
.voice-icon.playing {
background: rgba(36, 122, 99, 0.18);
}
/* Rich Message Bubbles */
.location-message {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
min-width: 180px;
max-width: 280px;
.location-icon {
box-sizing: border-box;
min-width: 34px;
padding: 4px 6px;
border-radius: var(--wxex-radius-sm);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.location-info {
flex: 1;
overflow: hidden;
}
.location-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.location-label {
font-size: 12px;
color: var(--wxex-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.location-coords {
font-size: 11px;
color: var(--wxex-text-muted);
margin-top: 2px;
}
}
.wechat-message-row.mine {
.location-message {
flex-direction: row-reverse;
}
}
.card-message {
display: flex;
align-items: center;
gap: 10px;
min-width: 160px;
max-width: 240px;
}
.card-avatar {
width: 40px;
height: 40px;
border-radius: 6px;
background: #07c160;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 500;
flex-shrink: 0;
}
.card-info {
flex: 1;
overflow: hidden;
}
.card-nickname {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-username {
font-size: 12px;
color: #666;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
color: #07c160;
}
}
.share-message {
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
min-width: 180px;
max-width: 280px;
}
.share-appname {
font-size: 11px;
color: #999;
}
.share-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.share-desc {
font-size: 12px;
color: #666;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.share-url {
font-size: 11px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-program-message {
width: min(280px, 48vw);
overflow: hidden;
color: var(--wxex-text-primary);
}
.mini-program-title {
margin-bottom: 8px;
font-size: 14px;
line-height: 20px;
word-break: break-word;
}
.mini-program-description {
margin: -4px 0 8px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
.mini-program-preview {
.image-bubble,
.image-content {
width: 100%;
max-width: 100%;
max-height: 220px;
border-radius: 4px;
}
.image-content {
object-fit: cover;
}
.image-actions {
display: none;
}
.image-content.image-fallback {
width: 64px;
height: 64px;
margin: 28px auto;
border-radius: 8px;
object-fit: cover;
}
}
.mini-program-inline-image {
display: block;
width: 100%;
max-height: 220px;
border-radius: 4px;
object-fit: cover;
cursor: zoom-in;
}
.mini-program-icon {
display: block;
width: 52px;
height: 52px;
margin: 8px 0;
border-radius: 6px;
object-fit: cover;
}
.mini-program-footer {
display: flex;
align-items: center;
gap: 5px;
margin-top: 8px;
padding-top: 7px;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 16px;
}
.red-packet-message {
width: min(280px, 48vw);
overflow: hidden;
border-radius: 6px;
background: #fa9d3b;
color: #fff;
&.is-clickable {
cursor: pointer;
}
}
.red-packet-main {
display: flex;
align-items: center;
gap: 12px;
min-height: 72px;
padding: 12px 14px;
}
.red-packet-icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border: 2px solid rgba(255, 239, 170, 0.92);
border-radius: 50%;
color: #fff1a8;
font-size: 18px;
font-weight: 700;
}
.red-packet-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.red-packet-copy strong {
overflow: hidden;
font-size: 15px;
font-weight: 500;
line-height: 21px;
text-overflow: ellipsis;
white-space: nowrap;
}
.red-packet-copy small {
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
line-height: 18px;
}
.red-packet-footer {
padding: 5px 14px;
background: #fff;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 16px;
}
.voip-message {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.voip-icon {
box-sizing: border-box;
padding: 3px 6px;
border-radius: var(--wxex-radius-sm);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 12px;
font-weight: 600;
}
.voip-status {
font-size: 13px;
}
File diff suppressed because it is too large Load Diff

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