Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e1294e5d | ||
|
|
224308f0e0 | ||
|
|
3e57a8432d | ||
|
|
b4f909a597 | ||
|
|
ee7dc11e92 | ||
|
|
90bf1aed90 | ||
|
|
a3955d691d | ||
|
|
8a6d443acc | ||
|
|
f0601cdc85 | ||
|
|
77adc744e0 | ||
|
|
0adb064681 | ||
|
|
8e40487e08 | ||
|
|
d0ae9e6019 | ||
|
|
49684f3365 | ||
|
|
33aaf4d558 | ||
|
|
5ccabb3898 | ||
|
|
7db845ac7e | ||
|
|
9348c5ce4a | ||
|
|
f626d89a1a | ||
|
|
95e517eca5 | ||
|
|
e93e4554d7 | ||
|
|
9ae0c6cc47 | ||
|
|
59e609b08a | ||
|
|
9de7dcde3b | ||
|
|
cb3c2855c0 | ||
|
|
dca88e5db3 | ||
|
|
1a9f27488b | ||
|
|
3b64e18b5e | ||
|
|
554dccdb21 | ||
|
|
c674dcbc4a | ||
|
|
0f65b96da1 | ||
|
|
8488e81bb5 | ||
|
|
430a36333b | ||
|
|
474250c6c7 |
@@ -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=
|
||||
@@ -19,3 +22,7 @@ VITE_FILTER_MSG_TYPES=
|
||||
# AES Key: 16-character string, derived from wxid and code
|
||||
VITE_IMAGE_XOR_KEY=
|
||||
VITE_IMAGE_AES_KEY=
|
||||
|
||||
# Electron E2E test window close delay in milliseconds.
|
||||
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
|
||||
WXE_E2E_CLOSE_DELAY_MS=2000
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
desktop-tests:
|
||||
name: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 7.33.7
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: services/wechat-connector/go.mod
|
||||
cache-dependency-path: services/wechat-connector/go.sum
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Component tests
|
||||
run: pnpm test:component
|
||||
|
||||
- name: IPC integration tests
|
||||
run: pnpm test:integration
|
||||
|
||||
- name: Skill installation instruction tests
|
||||
run: pnpm test:skill-install
|
||||
|
||||
- name: WeChat connector tests
|
||||
run: pnpm test:wechat-connector
|
||||
|
||||
- name: Build Electron test application
|
||||
run: pnpm test:e2e:build
|
||||
|
||||
- name: Electron E2E tests
|
||||
run: pnpm exec playwright test --grep-invert @visual
|
||||
env:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
- name: Platform visual regression
|
||||
run: pnpm exec playwright test tests/e2e/visual.spec.ts
|
||||
env:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
- name: Upload Playwright diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-${{ matrix.os }}
|
||||
path: |
|
||||
test-results/
|
||||
playwright-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -6,6 +6,9 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
resources/connectors/wechat/
|
||||
.omc
|
||||
.codex/
|
||||
@@ -13,3 +16,6 @@ docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
AGENTS.md
|
||||
findings.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
|
||||
@@ -1,174 +1,318 @@
|
||||
# WechatExplorer
|
||||
|
||||
macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
||||
<p align="center">
|
||||
<img src="./build/icon.png" width="120" alt="WechatExplorer Logo" />
|
||||
</p>
|
||||
|
||||
## 项目说明
|
||||
<h2 align="center">让 AI 读懂你的微信</h2>
|
||||
|
||||
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
|
||||
<p align="center">
|
||||
本地优先的 AI 微信助手<br />
|
||||
聊天记录查看 · AI 问问微信 · 群聊日报 · Agent · 本地 API
|
||||
</p>
|
||||
|
||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/stars/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="GitHub stars" />
|
||||
<img src="https://img.shields.io/github/downloads/Wxw-Gu/WechatExplorer/total?style=for-the-badge" alt="GitHub downloads" />
|
||||
<img src="https://img.shields.io/github/v/release/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="Latest release" />
|
||||
</p>
|
||||
|
||||
> 当前版本:`v2.1.2`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
||||
<p align="center">
|
||||
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>📦 下载最新版</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md"><b>🚀 第一次使用</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md#遇到问题"><b>📖 使用说明</b></a>
|
||||
</p>
|
||||
|
||||
## ✨ 功能特性
|
||||
> ⭐ 如果这个项目帮助到了你,欢迎点一个 Star,支持项目持续更新。
|
||||
|
||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||
- **全局搜索**: 快速搜索聊天内容。
|
||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
|
||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||
- **问题与解答**: 整理群内的问答内容。
|
||||
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
|
||||
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
|
||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云/关键词**: 可视化展示群聊关键词。
|
||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
||||
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||
<p align="center">
|
||||
<img src="./public/software-1.png" alt="WechatExplorer AI 微信助手界面" />
|
||||
</p>
|
||||
|
||||
## 📸 预览
|
||||
> 像问 ChatGPT 一样,直接询问你的微信聊天记录。
|
||||
|
||||
### 日报模板
|
||||
WechatExplorer 是一个基于 Electron + React + TypeScript 开发的本地优先 AI 微信助手。它不只是查看聊天记录,而是把聊天内容变成可以搜索、总结、分析和交给 Agent 使用的信息。
|
||||
|
||||
**支持:**
|
||||
|
||||
微信聊天记录查看、AI 微信助手、AI 群聊日报、MCP、Agent、本地 API
|
||||
|
||||
## ✨ 为什么选择 WechatExplorer?
|
||||
|
||||
- ✅ **像 ChatGPT 一样搜索整个微信**:用自然语言提问,快速找到聊天上下文。
|
||||
- ✅ **AI 自动生成群聊日报**:自动整理热点、资源、问答和待跟进事项。
|
||||
- ✅ **Agent 可直接读取微信聊天**:支持 Codex、Claude Code、MCP 等 AI 工作流。
|
||||
- ✅ **本地数据库优先**:聊天数据默认保存在本机,不会自动上传。
|
||||
- ✅ **支持微信 3.x / 4.x**:不同微信版本提供对应版本支持。
|
||||
- ✅ **多格式导出**:支持 HTML、Markdown、CSV 和 JSON。
|
||||
|
||||
## 🚀 第一次使用
|
||||
|
||||
软件已经内置完整的新手引导,通常按下面三步即可开始:
|
||||
|
||||
```text
|
||||
下载软件
|
||||
↓
|
||||
连接微信
|
||||
↓
|
||||
开始问你的微信
|
||||
```
|
||||
|
||||
首次启动会自动进入「第一次使用」页面。连接成功后,软件会显示「开始探索你的微信」;进入主界面后,还可以随时点击左下角「新手引导」重新查看。
|
||||
|
||||
## 📸 功能预览
|
||||
|
||||
### AI 群聊日报
|
||||
|
||||
<details>
|
||||
<summary>点击查看完整日报模板</summary>
|
||||
<br />
|
||||
<img src="./public/report-template-1.png" alt="完整日报模板" />
|
||||
<img src="./public/report-template-1.png" alt="完整群聊日报模板" />
|
||||
</details>
|
||||
|
||||
### AI 群聊日报界面
|
||||
### AI 问问微信
|
||||
|
||||
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
|
||||
<img src="./public/ai-search.png" alt="AI 问问微信页面" />
|
||||
|
||||
### 本地 API 与 Reader Skill
|
||||
### 本地 API 与 Agent
|
||||
|
||||
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
|
||||
<img src="./public/software-2.png" alt="本地 API 与 Agent 页面" />
|
||||
|
||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
## 🎯 它能帮你做什么
|
||||
|
||||
## 📦 安装说明
|
||||
### 🤖 AI 问问微信
|
||||
|
||||
### macOS 版本已测 4.1.8
|
||||
直接向自己的微信提问:
|
||||
|
||||
> 微信 macOS `4.1.8.100` 下载地址:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
|
||||
> “去年我和老板聊过哪些关于涨薪的事情?”
|
||||
>
|
||||
> “技术群这周讨论了哪些问题?”
|
||||
>
|
||||
> “帮我找到张三发过的项目地址。”
|
||||
|
||||
1. 下载 Releases 中的 `xxx.dmg` 文件。
|
||||
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
|
||||
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
||||
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
||||
4. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥
|
||||
### 📰 AI 群聊日报
|
||||
|
||||
### Windows 版本已测 4.1.9
|
||||
选择一个群聊和时间范围,自动生成:
|
||||
|
||||
> 微信 Windows `4.1.9.57` 下载地址:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
|
||||
- ✅ 今日热点
|
||||
- ✅ 一句话总结
|
||||
- ✅ 资源汇总
|
||||
- ✅ 问答整理
|
||||
- ✅ 活跃榜
|
||||
- ✅ 词云与关键词
|
||||
|
||||
1. 下载 Releases 中的 `xxx-setup.exe` 安装包。
|
||||
2. 双击安装,可按安装向导选择安装目录。
|
||||
3. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥。
|
||||
<details>
|
||||
<summary>展开查看日报的完整模块</summary>
|
||||
|
||||
> Windows 支持仍处于初步阶段。软件会有些卡顿, 如果自动获取密钥失败,请先确认微信客户端已登录并保持运行;不同微信安装路径、数据目录和权限环境可能仍需要继续适配。
|
||||
- **今日讨论热点**:梳理群内主要话题,支持热度标签。
|
||||
- **一句话速览**:首屏突出今日核心结论与待跟进事项。
|
||||
- **实用信息与资源**:提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**:标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**:收录群内的精彩对话。
|
||||
- **问题与解答**:整理群内的问答内容。
|
||||
- **尚未解决 / 今日剧情线**:适合工作群和项目群的回顾与跟进。
|
||||
- **今日群相册 / 语音时长榜 / 临时群友称号**:让图片、语音和氛围型内容也能参与日报。
|
||||
- **群内数据可视化**:消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云 / 关键词**:可视化展示群聊关键词。
|
||||
</details>
|
||||
|
||||
## 🚀 快速开始
|
||||
支持导出 HTML 与 PNG,也支持图片理解和图片生成。
|
||||
|
||||
### 使用前置要求
|
||||
### 📂 查看聊天
|
||||
|
||||
- **微信版本**:
|
||||
- 微信 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` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
||||
同时支持头像显示、全局搜索、指定会话搜索、消息防撤回和上下文定位。
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| ----------------------- | --------------------------- | --------------------------- |
|
||||
| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` |
|
||||
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` |
|
||||
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` |
|
||||
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
|
||||
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
|
||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||
### 📤 导出聊天
|
||||
|
||||
#### 图片解密密钥说明
|
||||
支持按会话和时间范围导出聊天记录为 HTML、CSV、JSON 或 Markdown,并可以打开文件所在文件夹。
|
||||
|
||||
微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密:
|
||||
### 🤖 Agent
|
||||
|
||||
- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密
|
||||
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
|
||||
通过本地 HTTP API 和内置 Reader Skill,让 Codex、Claude Code 等 Agent 在本机服务运行并获得授权后读取、总结聊天数据。
|
||||
|
||||
这两个密钥可以通过以下方式获取:
|
||||
## 🚀 规划与未来(Roadmap)
|
||||
|
||||
1. 在应用登录界面点击“自动获取密钥”
|
||||
2. 从 WeFlow/Chatlog 设置中导出
|
||||
3. 在软件“设置 -> 图片解密密钥”中自动获取或手动填写
|
||||
4. 如自动获取失败,可手动粘贴已获取的密钥
|
||||
WechatExplorer 仍在持续演进,未来会围绕 **AI 大模型 + 微信 + Agent** 持续完善能力。
|
||||
|
||||
## 🤖 AI 集成(本地 HTTP API)
|
||||
下面是正在设计或计划中的部分功能(不代表发布时间)。
|
||||
|
||||
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
||||
<details>
|
||||
<summary>点击展开未来规划</summary>
|
||||
|
||||
### 🚧 人物镜像(Persona)
|
||||
|
||||
根据长期聊天记录生成每个人的沟通画像:
|
||||
|
||||
- 兴趣标签
|
||||
- 常聊话题
|
||||
- 表达风格
|
||||
- 个性化沟通参考
|
||||
|
||||
### 🚧 AI 长期记忆
|
||||
|
||||
让 AI 持续理解你的聊天历史,在不同时间跨度内建立上下文,支持长期事项追踪和连续对话。
|
||||
|
||||
### 🚧 微信卡片分享
|
||||
|
||||
将 AI 日报生成可点击的微信卡片消息,而不仅仅是图片,方便在群聊中传播与查看。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/微信卡片分享.png" alt="微信卡片分享示例" width="520" />
|
||||
</p>
|
||||
|
||||
### 🚧 退群自动监控
|
||||
|
||||
自动记录群聊成员变动:
|
||||
|
||||
- 谁加入群聊
|
||||
- 谁退出群聊
|
||||
- 变动发生的时间
|
||||
- 群成员变动记录
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/退群监控.png" alt="退群自动监控示例" width="720" />
|
||||
</p>
|
||||
|
||||
### 💡 更多 AI 能力
|
||||
|
||||
包括会议纪要、聊天知识库、长期事项追踪、个人成长分析等更多探索。
|
||||
|
||||
WechatExplorer 希望不仅仅是一个聊天记录查看工具,更希望成为一个能够理解、整理和协助管理微信信息的 AI 工作平台。
|
||||
|
||||
如果你有好的想法,欢迎提交 Issue 或 Pull Request,一起把它做得更好。
|
||||
|
||||
</details>
|
||||
|
||||
## ⚙️ 快速开始
|
||||
|
||||
### 下载并安装
|
||||
|
||||
当前 WechatExplorer / 迹忆版本:`v2.1.6`。
|
||||
|
||||
应用安装包:[WechatExplorer GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。
|
||||
|
||||
| 系统 | 已测试的微信客户端 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) |
|
||||
|
||||
微信客户端来自上表对应的第三方版本存档,请自行核对来源与文件完整性。
|
||||
|
||||
正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录;但应用缓存和本地设置可能随版本升级变化。升级前仍建议使用微信官方迁移或备份功能备份重要记录,不要将唯一副本保存在单一设备。
|
||||
|
||||
### 连接微信
|
||||
|
||||
按照软件内置的「第一次使用」引导完成连接:
|
||||
|
||||
1. 确认微信数据目录。
|
||||
2. 让微信停在登录页面。
|
||||
3. 点击“开始获取”,按提示完成连接。
|
||||
|
||||
Windows 已完整支持,不需要关闭 SIP。macOS 首次自动获取数据库密钥前,需要关闭 SIP 并完成系统授权。
|
||||
|
||||
### 配置 AI
|
||||
|
||||
进入「设置 → AI 模型」,添加模型服务商并填写 API Key,保存并测试成功后即可使用「问问微信」和「日报」。支持:
|
||||
|
||||
- OpenAI
|
||||
- DeepSeek
|
||||
- Claude
|
||||
- Moonshot
|
||||
- OpenAI 兼容接口
|
||||
|
||||
### 下一步
|
||||
|
||||
| 你想做什么 | 从哪里开始 |
|
||||
| ----------------- | ---------------------------------------------------------------- |
|
||||
| 重新查看连接步骤 | 点击左下角「新手引导」 |
|
||||
| 直接向微信提问 | 打开「问问微信」 |
|
||||
| 生成群聊日报 | 打开「日报」 |
|
||||
| 浏览聊天记录 | 打开「档案」 |
|
||||
| 导出聊天记录 | 打开「导出」 |
|
||||
| 让 Agent 读取微信 | [Reader Skill 文档](./docs/skill/wechatexplorer-reader/SKILL.md) |
|
||||
|
||||
## 🖥️ 支持平台与微信版本
|
||||
|
||||
- **Windows**:已完整支持 Windows x64,不需要关闭 SIP。
|
||||
- **macOS**:支持 Intel 和 Apple Silicon;首次自动获取数据库密钥前,需要关闭 SIP 并完成系统授权。
|
||||
- **微信 3.0**:请使用 [v1.1.0 版本](https://github.com/Wxw-Gu/WechatExplorer/releases/tag/v1.1.0)。
|
||||
- **微信 4.0**:使用当前 Releases 中的最新版。
|
||||
|
||||
不同微信版本、账号和数据目录可能存在差异,遇到连接问题时请优先参考 [使用说明](./docs/user-guide/getting-started.md)。
|
||||
|
||||
## 🔒 隐私与权限
|
||||
|
||||
- WechatExplorer 只读取你有权访问的本机微信数据。
|
||||
- 不使用 AI 时,应用不会因为读取聊天记录而自动上传聊天内容。
|
||||
- 使用 AI 问问微信、日报或图片理解时,相关内容会发送到你配置的模型服务。
|
||||
- 本地 API 默认监听 `127.0.0.1`,无鉴权;请按可信网络范围配置。
|
||||
- 消息防撤回、图片解密和数据库密钥等能力都应只用于你有权访问的数据。
|
||||
|
||||
## 🔌 高级能力:本地 HTTP API 与 Agent
|
||||
|
||||
<details>
|
||||
<summary>展开本地 HTTP API、Reader Skill 和 Agent 说明</summary>
|
||||
|
||||
WechatExplorer 内置一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`,纯本地、无鉴权。完成数据库连接后,API 会自动启用。
|
||||
|
||||
### 启用本地 API
|
||||
|
||||
API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要:
|
||||
|
||||
1. 安装并启动 WechatExplorer
|
||||
2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库
|
||||
3. API 即在 `http://127.0.0.1:6131` 可用
|
||||
1. 安装并启动 WechatExplorer。
|
||||
2. 完成首次密钥配置,解锁 WCDB 数据库。
|
||||
3. 在 `http://127.0.0.1:6131` 使用本地 API。
|
||||
|
||||
### 7×24 提供 API(菜单栏常驻模式)
|
||||
|
||||
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,启用菜单栏模式:
|
||||
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,可以启用菜单栏模式:
|
||||
|
||||
```bash
|
||||
# 任选一种方式
|
||||
WXE_TRAY=1 open /Applications/WechatExplorer.app
|
||||
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
|
||||
```
|
||||
|
||||
启用后:
|
||||
|
||||
- macOS dock 图标自动隐藏
|
||||
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
|
||||
- 主窗口关闭后 API 服务继续运行
|
||||
- macOS Dock 图标自动隐藏。
|
||||
- 菜单栏出现 WechatExplorer 图标,可重新打开主窗口、查看 API 状态。
|
||||
- 主窗口关闭后 API 服务继续运行。
|
||||
|
||||
### API 端点一览
|
||||
|
||||
| 端点 | 说明 |
|
||||
| ------------------------------------------------ | --------------------------------------- |
|
||||
| `GET /api/v1/health` | 健康检查 |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间,用于“今天 / 昨天”换算 |
|
||||
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
|
||||
| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 |
|
||||
| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 |
|
||||
| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 |
|
||||
| `GET /api/v1/resolve?q=群昵称` | 把昵称/wxid/md5 解析成 md5 |
|
||||
| `GET /api/v1/resolve?q=群昵称` | 把昵称、wxid 或 md5 解析成 md5 |
|
||||
|
||||
详细参数、返回结构、时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
详细参数、返回结构和时间格式见 [Reader Skill 文档](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
### 安装 Reader Skill,让 Agent 读取和总结群聊
|
||||
|
||||
WechatExplorer 已内置 **Reader Skill**,无需手动复制仓库中的 `SKILL.md`:
|
||||
WechatExplorer 已内置 Reader Skill,无需手动复制仓库中的 `SKILL.md`:
|
||||
|
||||
1. 启动 WechatExplorer,并确认数据库已连接、本地 API 已运行。
|
||||
2. 打开应用内的 **API** 页面。
|
||||
3. 在“快速接入”中选择 **Codex** 或 **Claude Code**。
|
||||
4. 点击复制安装指令,将指令粘贴给对应的 Agent 执行。
|
||||
2. 打开应用内的「API」页面。
|
||||
3. 在“快速接入”中选择 Codex 或 Claude Code。
|
||||
4. 点击复制安装指令,将指令粘贴给对应 Agent 执行。
|
||||
5. 安装完成后,可以直接向 Agent 提问:
|
||||
|
||||
> “今天技术交流群聊了什么?”
|
||||
|
||||
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。详细接口说明仍可查看 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。
|
||||
|
||||
### curl 调试示例(可选)
|
||||
|
||||
@@ -178,7 +322,7 @@ Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
# 今天 摸鱼交流群 的聊天记录
|
||||
# 今天“摸鱼交流群”的聊天记录
|
||||
curl -G "http://127.0.0.1:6131/api/v1/chatlog" \
|
||||
--data-urlencode "talker=摸鱼交流群" \
|
||||
--data-urlencode "time=$(date +%Y-%m-%d)"
|
||||
@@ -188,18 +332,117 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||
--data-urlencode "q=摸鱼交流群"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🛠️ 开发配置(可选)
|
||||
|
||||
<details>
|
||||
<summary>展开开发配置、环境变量和构建命令</summary>
|
||||
|
||||
本地开发需要 Node.js(建议当前 LTS)和 pnpm 7+:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户不需要配置 `.env`,也可以直接在软件“设置”里填写 AI 和图片解密配置。
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| ----------------------- | ----------------------------- | --------------------------- |
|
||||
| `VITE_DB_KEY` | 微信数据库密钥(32 字节 hex) | `YOUR_DB_KEY_HERE` |
|
||||
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥(hex 格式) | `0x40` |
|
||||
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥(16 字符) | `YOUR_AES_KEY_HERE` |
|
||||
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
|
||||
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
|
||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
pnpm typecheck # 类型检查
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm build # 构建
|
||||
pnpm build:win # 构建 Windows x64 安装包
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary>展开常见问题</summary>
|
||||
|
||||
### 我已经连接成功,怎么重新查看教程?
|
||||
|
||||
点击左下角「新手引导」。首次连接流程、AI 配置入口、群聊日报、问问微信和完整教程都会再次展示。
|
||||
|
||||
### 微信 3.0 应该下载哪个版本?
|
||||
|
||||
请使用 [v1.1.0 版本](https://github.com/Wxw-Gu/WechatExplorer/releases/tag/v1.1.0)。微信 4.0 用户使用当前 Releases 中的最新版。
|
||||
|
||||
### AI 问问微信或群聊日报不可用怎么办?
|
||||
|
||||
进入「设置 → AI 模型」,添加模型服务商并填写 API Key,确认 Base URL 和模型名称正确,然后保存并测试连接。
|
||||
|
||||
### 连接失败怎么办?
|
||||
|
||||
请先查看 [使用说明](./docs/user-guide/getting-started.md) 的“遇到问题”部分,重点确认微信数据目录、微信登录状态、微信版本和 macOS SIP 设置。
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议,并仅处理你有权访问的数据。
|
||||
|
||||
## 🔗 参考致谢
|
||||
## ⭐ Star History
|
||||
|
||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||
- [chatlog](https://github.com/sjzar/chatlog)
|
||||
<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>
|
||||
|
||||
## 📱 交流与反馈
|
||||
## 致谢
|
||||
|
||||
<details>
|
||||
<summary>展开致谢与参考项目</summary>
|
||||
|
||||
WechatExplorer 在开发过程中参考了多个优秀的开源项目,感谢这些项目作者的工作与分享。
|
||||
|
||||
特别感谢:
|
||||
|
||||
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
|
||||
- 提供了微信数据库解析相关思路。
|
||||
- **[WeFlow](https://github.com/hicccc77/WeFlow)**
|
||||
- 参考了数据库密钥获取、图片解密等实现思路。
|
||||
- **[chatlog](https://github.com/sjzar/chatlog)**
|
||||
- 提供了聊天记录导出与数据处理方面的参考。
|
||||
|
||||
在此基础上,WechatExplorer 进行了重新设计与实现,包括:
|
||||
|
||||
- AI 问问微信
|
||||
- AI 群聊日报
|
||||
- 本地 HTTP API
|
||||
- Reader Skill
|
||||
- Agent Hub
|
||||
- 新手引导
|
||||
- Electron + React 全新界面
|
||||
- 本地优先 AI 工作流
|
||||
|
||||
感谢所有开源作者。
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 交流与反馈
|
||||
|
||||
请先完成 [第一次使用与问题排查](./docs/user-guide/getting-started.md),再查看问题排查和 FAQ。只有自助排查仍无法解决时,再扫码进入交流群。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" />
|
||||
<img src="./public/二维码.jpg" alt="WechatExplorer 交流与售后群二维码" width="280" />
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# WechatExplorer:第一次使用与问题排查
|
||||
|
||||
这份说明解决三件事:第一次连接微信、连接成功后如何开始使用,以及遇到问题时如何自助排查。
|
||||
|
||||
如果你已经进入软件,忘记了连接步骤,可以直接点击左下角「新手引导」,重新查看首次连接流程、AI 配置入口和群聊日报入口。
|
||||
|
||||
## 你现在要做什么
|
||||
|
||||
- [我第一次使用,想连接微信](#第一次连接微信)
|
||||
- [我已经连接成功,下一步做什么](#连接成功后做什么)
|
||||
- [我想重新查看引导](#重新查看新手引导)
|
||||
- [我想配置 AI](#配置-ai)
|
||||
- [我遇到问题](#遇到问题)
|
||||
- [我想让 Agent 读取微信](#接入-api-reader-skill-或-agent)
|
||||
|
||||
> 正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录。应用缓存和本地设置可能随版本升级发生变化。系统故障、磁盘异常、误操作和微信自身迁移不受本应用控制,因此升级前仍建议使用微信官方迁移或备份功能备份重要聊天记录,不要将唯一副本保存在单一设备。
|
||||
|
||||
## 开始前确认
|
||||
|
||||
| 系统 | 已测试的微信客户端 | 需要注意 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前需要关闭 SIP 并完成授权 |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 已完整支持;首次使用时请确认微信数据目录 |
|
||||
|
||||
- WechatExplorer 当前面向微信 4.0 数据结构。
|
||||
- Windows 不需要关闭 SIP。
|
||||
- macOS 首次自动获取数据库密钥需要按页面提示完成系统授权。
|
||||
- WechatExplorer 必须取得当前微信账号对应的数据库密钥才能读取聊天记录。
|
||||
- 请只处理你有权访问的微信数据。
|
||||
|
||||
WechatExplorer / 迹忆应用安装包:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。微信客户端请使用上方“已测试的微信客户端”链接。
|
||||
|
||||
## 第一次连接微信
|
||||
|
||||
### 1. 安装 WechatExplorer
|
||||
|
||||
#### Windows
|
||||
|
||||
1. 从 Releases 下载 Windows `-setup.exe` 安装包。
|
||||
2. 双击安装包,按向导完成安装。
|
||||
3. 启动 WechatExplorer。
|
||||
|
||||
#### macOS
|
||||
|
||||
1. 从 Releases 下载 `.dmg` 文件。
|
||||
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
|
||||
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
4. 如果系统提示应用已损坏,可在终端执行:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/WechatExplorer.app"
|
||||
```
|
||||
|
||||
5. 如果这是第一次在 macOS 上自动获取数据库密钥,先完成 [关闭 SIP 教程](../mac-disable-sip.md)。关闭 SIP 会降低系统安全性,完成密钥配置后建议重新开启。
|
||||
|
||||
### 2. 按软件内引导连接微信
|
||||
|
||||
首次启动会自动进入「第一次使用」页面。页面会根据当前系统显示连接方式和注意事项:
|
||||
|
||||
<p align="center">
|
||||
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
|
||||
</p>
|
||||
|
||||
通常按下面三步操作即可:
|
||||
|
||||
1. **确认微信数据目录**:自动识别不准确时,在页面中修改存储路径。
|
||||
2. **让微信停在登录页面**:如果微信已经登录,先退出微信登录,不只是关闭窗口。
|
||||
3. **点击开始获取**:软件会尝试获取数据库密钥。按提示可以登录后,再回到微信完成登录。
|
||||
|
||||
Windows 已完整支持,不需要关闭 SIP。macOS 首次获取密钥前,需要按页面提示完成授权并关闭 SIP。
|
||||
|
||||
### 3. 连接成功
|
||||
|
||||
连接成功后,软件会进入聊天档案,并显示「开始探索你的微信」引导:
|
||||
|
||||
<p align="center">
|
||||
<img src="../../public/first-use-welcome.png" alt="连接成功后的新手引导" width="760" />
|
||||
</p>
|
||||
|
||||
这里推荐先体验「AI 群聊日报」,也可以直接查看聊天、问问微信或配置 AI 模型。
|
||||
|
||||
## 连接成功后做什么
|
||||
|
||||
### AI 问问微信
|
||||
|
||||
打开「问问微信」,用自然语言向自己的微信提问,例如:
|
||||
|
||||
- “技术群这周讨论了哪些问题?”
|
||||
- “帮我找到张三发过的项目地址。”
|
||||
- “去年我和老板聊过哪些关于涨薪的事情?”
|
||||
|
||||
如果还没有配置 AI,点击「设置 → AI 模型」添加模型服务商并测试连接。
|
||||
|
||||
### AI 群聊日报
|
||||
|
||||
1. 打开「日报」。
|
||||
2. 选择一个群聊和时间范围。
|
||||
3. 按需要选择日报内容和模板。
|
||||
4. 开始生成,完成后查看或导出 HTML 与 PNG。
|
||||
|
||||
日报会整理讨论摘要、关键主题、重要消息、资源、问题和待跟进事项,并保留证据来源。
|
||||
|
||||
### 查看聊天
|
||||
|
||||
1. 打开「档案」。
|
||||
2. 选择好友或群聊。
|
||||
3. 浏览历史消息,也可以按关键词定位会话。
|
||||
|
||||
### 导出聊天
|
||||
|
||||
打开「导出」,选择联系人或群聊、时间范围和格式。支持 HTML、CSV、JSON 和 Markdown。
|
||||
|
||||
## 重新查看新手引导
|
||||
|
||||
连接成功后,首次弹窗关闭不会影响功能使用。需要重新查看时,点击主界面左下角的「新手引导」:
|
||||
|
||||
<p align="center">
|
||||
<img src="../../public/guide-entry.png" alt="主界面左下角新手引导入口" width="760" />
|
||||
</p>
|
||||
|
||||
新手引导会再次展示:
|
||||
|
||||
- AI 群聊日报入口。
|
||||
- 查看聊天记录入口。
|
||||
- 问问微信入口。
|
||||
- AI 模型配置入口。
|
||||
- 完整使用教程入口。
|
||||
|
||||
## 配置 AI
|
||||
|
||||
WechatExplorer 支持 OpenAI 兼容接口,也提供 DeepSeek、OpenAI、Claude、Moonshot 等常用配置方式。
|
||||
|
||||
1. 进入「设置 → AI 模型」。
|
||||
2. 添加模型服务商并填写 API Key。
|
||||
3. 确认 Base URL 和模型名称正确。
|
||||
4. 保存并测试连接。
|
||||
5. 返回「问问微信」或「日报」重试。
|
||||
|
||||
AI 功能使用你配置的模型服务。相关聊天内容会按请求发送给该服务;是否启用以及使用哪一个服务由你决定。
|
||||
|
||||
## 遇到问题
|
||||
|
||||
先判断你遇到的现象,再按对应路径处理。
|
||||
|
||||
| 现象 | 优先检查 |
|
||||
| --------------------------------- | -------------------------------------------------------- |
|
||||
| 软件打不开 | macOS 安全提示或应用损坏处理;Windows 重新运行安装包 |
|
||||
| 找不到微信数据 | 在首次连接页面或设置中确认数据目录,Windows 检查目录层级 |
|
||||
| 获取不到数据库密钥 | 微信是否停留在登录页面、微信和应用是否同时运行 |
|
||||
| 数据库连接失败 | 当前账号是否匹配、微信版本是否兼容、数据库目录是否正确 |
|
||||
| 已连接但图片不显示 | 配置图片 XOR Key 和 AES Key |
|
||||
| AI 问问微信或日报不可用 | 在「设置 → AI 模型」配置并测试模型服务 |
|
||||
| API、Reader Skill 或 Agent 不可用 | 先连接数据库,再确认 API 服务状态和对应配置 |
|
||||
|
||||
### 软件打不开
|
||||
|
||||
#### macOS
|
||||
|
||||
- 出现“无法打开,因为开发者无法验证”:前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
- 出现“应用已损坏”:确认应用位于“应用程序”目录,再执行:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/WechatExplorer.app"
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
确认下载的是 Releases 中的 `-setup.exe` 安装包,并按安装向导完成安装。Windows 不需要关闭 SIP。
|
||||
|
||||
### 找不到微信数据
|
||||
|
||||
在首次连接页面确认“存储路径”。如果没有自动识别:
|
||||
|
||||
1. 打开「设置」。
|
||||
2. 手动选择微信数据所在目录。
|
||||
3. 返回连接页面,重新测试连接。
|
||||
|
||||
Windows 当前不会扫描二级目录,请确认目录没有多选或少选一层目录。
|
||||
|
||||
### 获取不到数据库密钥
|
||||
|
||||
按顺序检查:
|
||||
|
||||
1. 微信版本是否与上方已测试版本一致。
|
||||
2. 点击“开始获取”时,微信是否停留在未登录页面。
|
||||
3. 微信和 WechatExplorer 是否都保持运行。
|
||||
4. 微信数据目录是否准确。
|
||||
5. macOS 是否已关闭 SIP 并完成系统授权。
|
||||
|
||||
仍然失败时,可以在连接页面切换为“高级用户:已有数据库密钥?手动连接”,粘贴从其他兼容工具中取得的数据库密钥。手动输入的密钥必须与当前微信账号匹配。
|
||||
|
||||
### 数据库连接失败或账号不匹配
|
||||
|
||||
数据库密钥与微信账号绑定。请确认:
|
||||
|
||||
- 当前微信登录的是获取密钥时对应的账号。
|
||||
- WechatExplorer 选择的是该账号的数据目录。
|
||||
- 没有把其他账号或旧数据目录的密钥粘贴进来。
|
||||
|
||||
### 已连接但图片无法显示
|
||||
|
||||
微信 4.0 的图片通常以 `.dat` 文件存储。显示图片还需要:
|
||||
|
||||
- **XOR Key**:单字节十六进制值,例如 `0x40`。
|
||||
- **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。
|
||||
|
||||
进入「设置 → 图片解密密钥」,选择自动获取或手动填写。也可以从 WeFlow 或 Chatlog 的设置中导出后填写。文字聊天记录不受图片密钥影响。
|
||||
|
||||
### 我已经连接成功,怎么重新查看教程?
|
||||
|
||||
点击左下角「新手引导」。
|
||||
|
||||
首次连接流程、AI 配置入口、群聊日报、问问微信和完整教程都会再次展示。
|
||||
|
||||
## 接入 API、Reader Skill 或 Agent
|
||||
|
||||
这是高级使用路径,请先完成数据库连接并熟悉「问问微信、日报、档案、导出」的基础流程。
|
||||
|
||||
### Reader Skill
|
||||
|
||||
1. 打开应用的「API」页面。
|
||||
2. 确认本地 API 已运行;如果已停止,点击“启动服务”。
|
||||
3. 在“快速接入”中选择 Codex 或 Claude Code。
|
||||
4. 复制安装指令,粘贴给对应 Agent 执行。
|
||||
5. 安装完成后,让 Agent 读取和总结本地聊天。
|
||||
|
||||
本地 API 默认地址为 `http://127.0.0.1:6131`,默认仅监听本机且无鉴权。详细端点和参数见 [Reader Skill 文档](../skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
### Agent Hub
|
||||
|
||||
应用内的「Agent」页面用于管理 WechatExplorer 的 Agent 连接与运行状态,属于高级功能。
|
||||
|
||||
## 数据与隐私
|
||||
|
||||
- WechatExplorer 只读取你有权访问的本机微信数据。
|
||||
- 不使用 AI 时,应用不会因为读取聊天记录而自动上传聊天内容。
|
||||
- 使用 AI 问问微信、日报或图片理解时,相关内容会发送到你配置的模型服务。
|
||||
- 本地 API 默认监听 `127.0.0.1`,且无鉴权。不要将它暴露在不可信的局域网环境中。
|
||||
|
||||
## 仍然无法解决?
|
||||
|
||||
请先完成上面的自助排查,再进入交流/售后群。提问时一次性提供:
|
||||
|
||||
1. 操作系统和版本。
|
||||
2. 微信版本。
|
||||
3. WechatExplorer 版本。
|
||||
4. 当前处于哪一步,以及完整错误信息。
|
||||
5. 必要截图;请遮挡账号、数据库密钥、API Key 和其他敏感信息。
|
||||
|
||||
交流二维码位于项目 [README](../../README.md) 文末。
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 214 KiB |
@@ -68,4 +68,4 @@ publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: WechatExplorer
|
||||
releaseType: draft
|
||||
releaseType: release
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "2.1.4",
|
||||
"version": "2.1.6",
|
||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
@@ -28,6 +29,13 @@
|
||||
"start": "electron-vite preview",
|
||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||
"test:component": "vitest run --config vitest.component.config.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:e2e:build": "electron-vite build",
|
||||
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
|
||||
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
|
||||
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
||||
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
||||
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
|
||||
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64",
|
||||
@@ -41,6 +49,8 @@
|
||||
"release": "npm run release:mac && npm run release:win",
|
||||
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always",
|
||||
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
||||
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -48,6 +58,8 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"jsonrepair": "^3.15.0",
|
||||
@@ -60,12 +72,18 @@
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@rollup/rollup-darwin-arm64": "^4.62.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"electron": "^43.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
@@ -73,11 +91,14 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"jsdom": "^30.0.1",
|
||||
"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"
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testMatch: /.*\.spec\.ts/,
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 8_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI
|
||||
? [['line'], ['html', { outputFolder: 'playwright-report', open: 'never' }]]
|
||||
: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
outputDir: 'test-results',
|
||||
snapshotPathTemplate: 'tests/e2e/__screenshots__/{platform}/{testFilePath}/{arg}{ext}',
|
||||
use: {
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure'
|
||||
}
|
||||
})
|
||||
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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
|
||||
@@ -12,6 +13,7 @@ const sanitize = (value: unknown, depth = 0): unknown => {
|
||||
return value
|
||||
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
|
||||
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
|
||||
.replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***')
|
||||
.slice(0, 2000)
|
||||
}
|
||||
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
|
||||
@@ -52,14 +54,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),
|
||||
message: String(sanitize(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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import type { DatabaseKeyStorageResult } from '../shared/database-key'
|
||||
|
||||
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
|
||||
@@ -9,32 +10,42 @@ export const isValidDatabaseKey = (value: string): boolean =>
|
||||
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
|
||||
|
||||
export class DatabaseKeyStore {
|
||||
private get filePath(): string {
|
||||
private get legacyFilePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
private get directoryPath(): string {
|
||||
return path.join(app.getPath('userData'), 'database-keys')
|
||||
}
|
||||
|
||||
private filePath(accountRoot: string): string {
|
||||
const normalized = path.resolve(accountRoot).toLowerCase()
|
||||
const id = crypto.createHash('sha256').update(normalized).digest('hex')
|
||||
return path.join(this.directoryPath, `${id}.bin`)
|
||||
}
|
||||
|
||||
async getStatus(accountRoot: string): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
return {
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: Boolean(accountRoot) && (await fs.pathExists(this.filePath(accountRoot))),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<DatabaseKeyStorageResult> {
|
||||
async load(accountRoot: string): Promise<DatabaseKeyStorageResult> {
|
||||
try {
|
||||
const status = await this.getStatus()
|
||||
const status = await this.getStatus(accountRoot)
|
||||
if (!status.saved) return { success: true, ...status }
|
||||
if (!status.encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', ...status }
|
||||
}
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const encrypted = await fs.readFile(this.filePath(accountRoot))
|
||||
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return { success: false, error: '已保存的密钥格式无效', ...status }
|
||||
}
|
||||
return { success: true, key, ...status }
|
||||
} catch (error) {
|
||||
const status = await this.getStatus()
|
||||
const status = await this.getStatus(accountRoot)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -43,13 +54,49 @@ export class DatabaseKeyStore {
|
||||
}
|
||||
}
|
||||
|
||||
async save(rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
async loadLegacy(): Promise<DatabaseKeyStorageResult> {
|
||||
const saved = await fs.pathExists(this.legacyFilePath)
|
||||
const encryptionAvailable = safeStorage.isEncryptionAvailable()
|
||||
if (!saved) return { success: true, saved, encryptionAvailable }
|
||||
if (!encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', saved, encryptionAvailable }
|
||||
}
|
||||
try {
|
||||
const key = normalizeDatabaseKey(
|
||||
safeStorage.decryptString(await fs.readFile(this.legacyFilePath))
|
||||
)
|
||||
return isValidDatabaseKey(key)
|
||||
? { success: true, key, saved, encryptionAvailable }
|
||||
: { success: false, error: '旧版密钥格式无效', saved, encryptionAvailable }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved,
|
||||
encryptionAvailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearLegacy(): Promise<void> {
|
||||
await fs.remove(this.legacyFilePath)
|
||||
}
|
||||
|
||||
async save(accountRoot: string, rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
const key = normalizeDatabaseKey(rawKey)
|
||||
if (!accountRoot.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '请先选择微信账号',
|
||||
saved: false,
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '密钥必须是 64 位十六进制字符',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
@@ -57,29 +104,30 @@ export class DatabaseKeyStore {
|
||||
return {
|
||||
success: false,
|
||||
error: '系统安全存储不可用',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.ensureDir(path.dirname(this.filePath))
|
||||
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
const filePath = this.filePath(accountRoot)
|
||||
await fs.ensureDir(this.directoryPath)
|
||||
await fs.writeFile(filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(filePath, 0o600)
|
||||
return { success: true, key, saved: true, encryptionAvailable: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<{ success: boolean; error?: string }> {
|
||||
async clear(accountRoot: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await fs.remove(this.filePath)
|
||||
if (accountRoot) await fs.remove(this.filePath(accountRoot))
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.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}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}`
|
||||
const safe = (value: unknown): string =>
|
||||
String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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="${safe(m.voiceDataUrl)}"></audio></div>`
|
||||
: ''
|
||||
const mediaStatus = m.exportMediaError
|
||||
? `<div class="media-status">${safe(m.exportMediaError)}</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>${mediaStatus}</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"><button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭图片预览">×</button><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'),closeButton=document.querySelector('#lightbox-close');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const closeLightbox=()=>{box.classList.remove('open');zoom=1;updateZoom()};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)closeLightbox()});closeButton.addEventListener('click',closeLightbox);document.addEventListener('keydown',event=>{if(event.key==='Escape')closeLightbox()});update()})()</script></body></html>`
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
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'
|
||||
import { getImageExportAttempts } from '../shared/export-media'
|
||||
|
||||
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()
|
||||
|
||||
const keepMediaError = (request: ExportRequest, message: Message, error: string): void => {
|
||||
if (request.keepMissing !== false) message.exportMediaError = error
|
||||
}
|
||||
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}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
|
||||
return [
|
||||
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
||||
...messages.map((m) =>
|
||||
[
|
||||
m.datetime,
|
||||
m.name || (m.isSender ? '我' : '联系人'),
|
||||
m.type,
|
||||
m.content,
|
||||
m.exportMediaUrl || m.voiceDataUrl || '',
|
||||
m.exportMediaError || ''
|
||||
]
|
||||
.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.exportMediaUrl = undefined
|
||||
message.exportMediaType = undefined
|
||||
message.exportMediaError = undefined
|
||||
message.voiceDataUrl = undefined
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||
if (mappedName) message.name = mappedName
|
||||
if (
|
||||
request.format !== 'html' &&
|
||||
['image', 'video', 'voice', 'sticker'].includes(kindOf(message))
|
||||
) {
|
||||
message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件'
|
||||
}
|
||||
}
|
||||
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') continue
|
||||
if (!message.sessionId || message.localId == null || !message.createTime) {
|
||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const voice = await voiceService.resolveVoice(
|
||||
message.sessionId,
|
||||
message.localId,
|
||||
message.createTime,
|
||||
message.serverId
|
||||
)
|
||||
if (!voice.success || !voice.data) {
|
||||
const detail = voice.error || '未知原因'
|
||||
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
|
||||
? `语音文件缺失:${detail}`
|
||||
: /Silk|解码|数据为空/.test(detail)
|
||||
? `语音解析失败:${detail}`
|
||||
: `语音格式不支持或读取失败:${detail}`
|
||||
keepMediaError(request, message, reason)
|
||||
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)))
|
||||
} catch (error) {
|
||||
keepMediaError(
|
||||
request,
|
||||
message,
|
||||
`语音文件写入失败:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (request.includeMedia) {
|
||||
for (const message of messages) {
|
||||
if (kindOf(message) === 'voice') {
|
||||
keepMediaError(request, message, '数据库未连接,无法读取本地语音')
|
||||
}
|
||||
}
|
||||
}
|
||||
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') {
|
||||
if (!imageService) {
|
||||
keepMediaError(request, message, '未配置图片解密密钥,无法导出图片')
|
||||
} else {
|
||||
let fileFound = false
|
||||
let decryptedImage: { data: string; filePath: string } | null = null
|
||||
let usedFallback = false
|
||||
for (const attempt of getImageExportAttempts(request)) {
|
||||
const file = imageService.findImageFile(
|
||||
message.contentData.md5,
|
||||
message.contentData.datName,
|
||||
{
|
||||
allowThumbnail: attempt.allowThumbnail,
|
||||
preferThumbnail: attempt.preferThumbnail,
|
||||
sessionId: message.sessionId
|
||||
}
|
||||
)
|
||||
if (!file) continue
|
||||
fileFound = true
|
||||
const decrypted = imageService.decryptImageToBase64WithFallback(
|
||||
file,
|
||||
attempt.allowThumbnail
|
||||
)
|
||||
if (!decrypted) continue
|
||||
decryptedImage = decrypted
|
||||
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
|
||||
break
|
||||
}
|
||||
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.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'
|
||||
if (usedFallback) {
|
||||
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
||||
}
|
||||
} else if (!fileFound) {
|
||||
keepMediaError(
|
||||
request,
|
||||
message,
|
||||
request.fallbackThumbnail === false
|
||||
? '原图文件缺失,未启用缩略图降级'
|
||||
: '原图和缩略图文件均缺失'
|
||||
)
|
||||
} else {
|
||||
keepMediaError(request, message, '图片解析失败或当前格式不支持')
|
||||
}
|
||||
}
|
||||
} else if (message.contentData.type === 'video') {
|
||||
const hashes = [
|
||||
message.contentData.md5,
|
||||
message.contentData.newMd5,
|
||||
message.contentData.rawMd5
|
||||
].filter((value): value is string => Boolean(value))
|
||||
if (!videoService) {
|
||||
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
|
||||
} else if (hashes.length === 0) {
|
||||
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
|
||||
} else {
|
||||
const resolved = videoService.resolve(hashes)
|
||||
const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined
|
||||
if (!resolved.success || !source) {
|
||||
keepMediaError(request, message, resolved.error || '视频文件缺失或已移动')
|
||||
} else if (extname(source).toLowerCase() !== '.mp4') {
|
||||
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
|
||||
} else {
|
||||
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'
|
||||
} else {
|
||||
keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,35 @@ type ShareContent = {
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type ForwardedMessageItem = {
|
||||
messageType: number
|
||||
sender?: string
|
||||
sentAt?: string
|
||||
text: string
|
||||
nested?: ForwardedMessageItem[]
|
||||
}
|
||||
type ForwardBundleContent = {
|
||||
type: 'forwardBundle'
|
||||
title: string
|
||||
description?: string
|
||||
items: ForwardedMessageItem[]
|
||||
}
|
||||
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 +53,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,9 +78,24 @@ type QuoteContent = {
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
quotedImageMd5?: string
|
||||
quotedImageDatName?: string
|
||||
}
|
||||
type SystemContent = { type: 'system'; content: string; raw?: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: 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 UnknownContent = { type: 'unknown'; raw: string; messageType?: string | number }
|
||||
|
||||
export type ParsedContent =
|
||||
| TextContent
|
||||
@@ -50,8 +103,12 @@ export type ParsedContent =
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| ForwardBundleContent
|
||||
| MiniProgramContent
|
||||
| RedPacketContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| VideoContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
@@ -65,10 +122,16 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
const normalized = content.trim()
|
||||
|
||||
switch (messageType) {
|
||||
case 1:
|
||||
return { type: 'text', content: normalized }
|
||||
case 34:
|
||||
return { type: 'voice' }
|
||||
case 3:
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
return parseCardMessage(normalized)
|
||||
case 43:
|
||||
return parseVideoMessage(normalized)
|
||||
case 47:
|
||||
return parseStickerMessage(normalized)
|
||||
case 48:
|
||||
@@ -81,13 +144,35 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
case 10002:
|
||||
return parseSystemMessage(normalized)
|
||||
default:
|
||||
return { type: 'text', content: normalized }
|
||||
return { type: 'unknown', raw: normalized, messageType }
|
||||
}
|
||||
}
|
||||
|
||||
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 +198,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 +430,13 @@ function parseLocationMessage(content: string): ParsedContent {
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '19' || /<recorditem\b|<dataitem\b/i.test(content)) {
|
||||
return parseForwardBundle(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 +446,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 +490,101 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
}
|
||||
|
||||
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
||||
function parseForwardBundle(content: string): ForwardBundleContent {
|
||||
const normalized = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const title = decodeXmlEntities(extractXmlValue(normalized, 'title')) || '聊天记录'
|
||||
const description = decodeXmlEntities(extractXmlValue(normalized, 'des')) || undefined
|
||||
const containers = Array.from(
|
||||
normalized.matchAll(/<recorditem\b[^>]*>([\s\S]*?)<\/recorditem>/gi),
|
||||
(match) => match[1] || ''
|
||||
)
|
||||
const sources = containers.length ? containers : [normalized]
|
||||
const items = dedupeForwardedItems(sources.flatMap((source) => parseForwardedItems(source)))
|
||||
return { type: 'forwardBundle', title, description, items }
|
||||
}
|
||||
|
||||
function parseForwardedItems(container: string, depth = 0): ForwardedMessageItem[] {
|
||||
if (!container || depth > 4) return []
|
||||
const variants = new Set<string>([container, decodeXmlEntities(container)])
|
||||
for (const match of container.matchAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g)) {
|
||||
if (match[1]) variants.add(decodeXmlEntities(match[1]))
|
||||
}
|
||||
|
||||
const items: ForwardedMessageItem[] = []
|
||||
for (const variant of variants) {
|
||||
for (const match of variant.matchAll(/<dataitem\b([^>]*)>([\s\S]*?)<\/dataitem>/gi)) {
|
||||
const attributes = match[1] || ''
|
||||
const body = match[2] || ''
|
||||
const attrType = /datatype\s*=\s*["']?(\d+)/i.exec(attributes)?.[1]
|
||||
const messageType = Number.parseInt(attrType || extractXmlValue(body, 'datatype') || '0', 10)
|
||||
const sender = decodeXmlEntities(extractXmlValue(body, 'sourcename')) || undefined
|
||||
const sentAt = extractXmlValue(body, 'sourcetime') || undefined
|
||||
const title = decodeXmlEntities(extractXmlValue(body, 'datatitle'))
|
||||
const description = decodeXmlEntities(
|
||||
extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')
|
||||
)
|
||||
const nestedXml = extractXmlBody(body, 'recordxml')
|
||||
const nested =
|
||||
messageType === 17 && nestedXml
|
||||
? parseForwardedItems(decodeXmlEntities(nestedXml), depth + 1)
|
||||
: undefined
|
||||
const text = description || title || forwardedTypeLabel(messageType)
|
||||
if (!sender && !text && !nested?.length) continue
|
||||
items.push({
|
||||
messageType: Number.isFinite(messageType) ? messageType : 0,
|
||||
sender,
|
||||
sentAt,
|
||||
text: text || '[消息]',
|
||||
nested: nested?.length ? nested : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
return dedupeForwardedItems(items)
|
||||
}
|
||||
|
||||
function dedupeForwardedItems(items: ForwardedMessageItem[]): ForwardedMessageItem[] {
|
||||
const seen = new Set<string>()
|
||||
return items.filter((item) => {
|
||||
const key = `${item.messageType}|${item.sender || ''}|${item.sentAt || ''}|${item.text}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function forwardedTypeLabel(messageType: number): string {
|
||||
switch (messageType) {
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
return '[语音]'
|
||||
case 43:
|
||||
return '[视频]'
|
||||
case 47:
|
||||
return '[表情包]'
|
||||
case 8:
|
||||
case 49:
|
||||
return '[文件或分享]'
|
||||
case 17:
|
||||
return '[聊天记录]'
|
||||
default:
|
||||
return '[消息]'
|
||||
}
|
||||
}
|
||||
|
||||
function extractXmlBody(xml: string, tagName: string): string {
|
||||
const match = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i').exec(xml)
|
||||
if (!match?.[1]) return ''
|
||||
return match[1].replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1').trim()
|
||||
}
|
||||
|
||||
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 +600,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 +637,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 +702,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 +822,69 @@ 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]
|
||||
|
||||
@@ -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'))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { AccountDiscoveryResult, WechatAccountCandidate } from '../../shared/database-key'
|
||||
import { DatabaseKeyStore } from '../database-key-store'
|
||||
import { getBootstrapCache } from './bootstrap-cache'
|
||||
import { validateDbRoot } from './settings-store'
|
||||
|
||||
function accountId(accountRoot: string): string {
|
||||
return crypto.createHash('sha256').update(path.resolve(accountRoot).toLowerCase()).digest('hex')
|
||||
}
|
||||
|
||||
export async function discoverAccounts(
|
||||
inputPath: string,
|
||||
keyStore: DatabaseKeyStore,
|
||||
currentAccountRoot?: string
|
||||
): Promise<AccountDiscoveryResult> {
|
||||
const validation = validateDbRoot(inputPath)
|
||||
if (!validation.valid) return { success: false, accounts: [], error: validation.error }
|
||||
|
||||
const normalizedInput = path.resolve(inputPath)
|
||||
const isAccount = await fs.pathExists(path.join(normalizedInput, 'db_storage'))
|
||||
const roots = isAccount
|
||||
? [normalizedInput]
|
||||
: (await fs.readdir(normalizedInput, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(normalizedInput, entry.name))
|
||||
.filter((candidate) => fs.existsSync(path.join(candidate, 'db_storage')))
|
||||
|
||||
const accounts: WechatAccountCandidate[] = await Promise.all(
|
||||
roots.map(async (accountRoot) => {
|
||||
const cached = getBootstrapCache(accountRoot)?.self
|
||||
return {
|
||||
id: accountId(accountRoot),
|
||||
accountRoot,
|
||||
directoryName: path.basename(accountRoot),
|
||||
wxid: cached?.wxid,
|
||||
nickname: cached?.nickname,
|
||||
avatar: cached?.avatar,
|
||||
hasSavedDbKey: (await keyStore.getStatus(accountRoot)).saved,
|
||||
loginStatus: currentAccountRoot
|
||||
? path.resolve(currentAccountRoot).toLowerCase() ===
|
||||
path.resolve(accountRoot).toLowerCase()
|
||||
? 'current'
|
||||
: 'other'
|
||||
: 'unknown',
|
||||
selectedByInput: isAccount
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
inputKind: isAccount ? 'account' : 'root',
|
||||
accounts,
|
||||
preselectedAccountId: isAccount ? accounts[0]?.id : undefined
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -11,136 +11,397 @@ export interface CachedSelfInfo {
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
interface CachedMessageBucket {
|
||||
export interface CachedGroupSnapshot {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface StartupCacheFile {
|
||||
version: 2
|
||||
platform: NodeJS.Platform
|
||||
accountRoot: string
|
||||
updatedAt: number
|
||||
self?: CachedSelfInfo
|
||||
contacts: Contact[]
|
||||
}
|
||||
|
||||
interface CachedMessageBucketFile {
|
||||
version: 2
|
||||
platform: NodeJS.Platform
|
||||
accountRoot: string
|
||||
cacheKey: string
|
||||
updatedAt: number
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
items: Message[]
|
||||
}
|
||||
|
||||
interface BootstrapCacheFile {
|
||||
version: 1
|
||||
interface CachedGroupSnapshotFile {
|
||||
version: 2
|
||||
platform: NodeJS.Platform
|
||||
accountRoot: string
|
||||
userMd5: string
|
||||
updatedAt: number
|
||||
self?: CachedSelfInfo
|
||||
contacts?: Contact[]
|
||||
messages?: Record<string, CachedMessageBucket>
|
||||
snapshot: CachedGroupSnapshot
|
||||
}
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_MESSAGE_BUCKETS = 24
|
||||
const MAX_MESSAGES_PER_BUCKET = 1200
|
||||
interface ScheduledWrite {
|
||||
value: unknown
|
||||
revision: number
|
||||
generation: number
|
||||
cleanupFile?: string
|
||||
prune?: { directory: string; maxFiles: number }
|
||||
}
|
||||
|
||||
interface AccountCachePaths {
|
||||
root: string
|
||||
startup: string
|
||||
messages: string
|
||||
groups: string
|
||||
legacy: string
|
||||
}
|
||||
|
||||
const CACHE_VERSION = 2
|
||||
const MAX_MESSAGE_BUCKETS = 768
|
||||
const MAX_GROUP_SNAPSHOTS = 768
|
||||
const MAX_MESSAGES_PER_BUCKET = 120
|
||||
const MAX_MEMORY_MESSAGE_BUCKETS = 32
|
||||
const MAX_MEMORY_GROUP_SNAPSHOTS = 32
|
||||
const WRITE_DEBOUNCE_MS = 300
|
||||
const memoryCache = new Map<string, BootstrapCacheFile>()
|
||||
const PRUNE_INTERVAL_MS = 30_000
|
||||
|
||||
const startupMemory = new Map<string, StartupCacheFile>()
|
||||
const messageMemory = new Map<string, CachedMessageBucketFile>()
|
||||
const groupMemory = new Map<string, CachedGroupSnapshotFile>()
|
||||
const writeTimers = new Map<string, NodeJS.Timeout>()
|
||||
const writeQueues = new Map<string, Promise<void>>()
|
||||
const scheduledWrites = new Map<string, ScheduledWrite>()
|
||||
const writeRevisions = new Map<string, number>()
|
||||
const lastPrunedAt = new Map<string, number>()
|
||||
let cacheGeneration = 0
|
||||
|
||||
function normalizeRoot(accountRoot?: string): string {
|
||||
return String(accountRoot || '').trim()
|
||||
}
|
||||
|
||||
function getCacheFile(accountRoot?: string): string {
|
||||
const normalizedRoot = normalizeRoot(accountRoot) || 'default'
|
||||
const hash = crypto
|
||||
.createHash('sha1')
|
||||
.update(`${process.platform}:${normalizedRoot}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
return path.join(
|
||||
app.getPath('userData'),
|
||||
'cache',
|
||||
'bootstrap',
|
||||
`${process.platform}-${hash}.json`
|
||||
)
|
||||
function digest(value: string): string {
|
||||
return crypto.createHash('sha1').update(value).digest('hex').slice(0, 24)
|
||||
}
|
||||
|
||||
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
function getAccountCachePaths(accountRoot: string): AccountCachePaths {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getCacheFile(normalizedRoot)
|
||||
const cached = memoryCache.get(file)
|
||||
if (cached) return cached
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
||||
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
||||
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
||||
const result: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
self: raw.self,
|
||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
||||
}
|
||||
memoryCache.set(file, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] read failed:', error)
|
||||
return null
|
||||
const accountKey = digest(`${process.platform}:${normalizedRoot}`)
|
||||
const bootstrapRoot = path.join(app.getPath('userData'), 'cache', 'bootstrap')
|
||||
const root = path.join(bootstrapRoot, `${process.platform}-${accountKey}`)
|
||||
return {
|
||||
root,
|
||||
startup: path.join(root, 'startup.json'),
|
||||
messages: path.join(root, 'messages'),
|
||||
groups: path.join(root, 'groups'),
|
||||
legacy: path.join(bootstrapRoot, `${process.platform}-${accountKey.slice(0, 16)}.json`)
|
||||
}
|
||||
}
|
||||
|
||||
function writeCacheFile(cache: BootstrapCacheFile): void {
|
||||
const file = getCacheFile(cache.accountRoot)
|
||||
memoryCache.set(file, cache)
|
||||
const existingTimer = writeTimers.get(file)
|
||||
if (existingTimer) clearTimeout(existingTimer)
|
||||
writeTimers.set(
|
||||
file,
|
||||
setTimeout(() => {
|
||||
writeTimers.delete(file)
|
||||
const serialized = JSON.stringify(memoryCache.get(file) || cache)
|
||||
const previous = writeQueues.get(file) || Promise.resolve()
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.ensureDir(path.dirname(file))
|
||||
await fs.writeFile(file, serialized, 'utf8')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (writeQueues.get(file) === next) writeQueues.delete(file)
|
||||
})
|
||||
writeQueues.set(file, next)
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const existing = readCacheFile(normalizedRoot)
|
||||
if (existing) return existing
|
||||
const created: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: [],
|
||||
messages: {}
|
||||
}
|
||||
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||
return created
|
||||
}
|
||||
|
||||
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
||||
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
||||
}
|
||||
|
||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||
const entries = Object.entries(messages)
|
||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||
entries
|
||||
.sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0))
|
||||
.slice(MAX_MESSAGE_BUCKETS)
|
||||
.forEach(([key]) => {
|
||||
delete messages[key]
|
||||
function getMessageCacheFile(accountRoot: string, cacheKey: string): string {
|
||||
return path.join(getAccountCachePaths(accountRoot).messages, `${digest(cacheKey)}.json`)
|
||||
}
|
||||
|
||||
function getGroupCacheFile(accountRoot: string, userMd5: string): string {
|
||||
return path.join(getAccountCachePaths(accountRoot).groups, `${digest(userMd5)}.json`)
|
||||
}
|
||||
|
||||
function readScheduledValue<T>(file: string): T | null {
|
||||
const scheduled = scheduledWrites.get(file)
|
||||
return scheduled ? (scheduled.value as T) : null
|
||||
}
|
||||
|
||||
function touchMemory<T>(memory: Map<string, T>, file: string, value: T, maxEntries: number): void {
|
||||
memory.delete(file)
|
||||
memory.set(file, value)
|
||||
while (memory.size > maxEntries) {
|
||||
const oldest = memory.keys().next().value
|
||||
if (!oldest) break
|
||||
memory.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentAccountFile(
|
||||
value: {
|
||||
version?: number
|
||||
platform?: NodeJS.Platform
|
||||
accountRoot?: string
|
||||
},
|
||||
accountRoot: string
|
||||
): boolean {
|
||||
return (
|
||||
value.version === CACHE_VERSION &&
|
||||
value.platform === process.platform &&
|
||||
normalizeRoot(value.accountRoot) === normalizeRoot(accountRoot)
|
||||
)
|
||||
}
|
||||
|
||||
function readStartupCacheFile(accountRoot: string): StartupCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getAccountCachePaths(normalizedRoot).startup
|
||||
const scheduled = readScheduledValue<StartupCacheFile>(file)
|
||||
if (scheduled) return scheduled
|
||||
const memory = startupMemory.get(file)
|
||||
if (memory) return memory
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<StartupCacheFile>
|
||||
if (!isCurrentAccountFile(raw, normalizedRoot)) return null
|
||||
const result: StartupCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
self: raw.self,
|
||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : []
|
||||
}
|
||||
startupMemory.set(file, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] startup read failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readMessageBucketFile(
|
||||
accountRoot: string,
|
||||
cacheKey: string
|
||||
): CachedMessageBucketFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getMessageCacheFile(normalizedRoot, cacheKey)
|
||||
const scheduled = readScheduledValue<CachedMessageBucketFile>(file)
|
||||
if (scheduled) return scheduled
|
||||
const memory = messageMemory.get(file)
|
||||
if (memory) {
|
||||
touchMemory(messageMemory, file, memory, MAX_MEMORY_MESSAGE_BUCKETS)
|
||||
return memory
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<CachedMessageBucketFile>
|
||||
if (!isCurrentAccountFile(raw, normalizedRoot) || raw.cacheKey !== cacheKey) return null
|
||||
const result: CachedMessageBucketFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
cacheKey,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
startTime: raw.startTime,
|
||||
endTime: raw.endTime,
|
||||
items: Array.isArray(raw.items) ? raw.items : []
|
||||
}
|
||||
touchMemory(messageMemory, file, result, MAX_MEMORY_MESSAGE_BUCKETS)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] message read failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readGroupSnapshotFile(
|
||||
accountRoot: string,
|
||||
userMd5: string
|
||||
): CachedGroupSnapshotFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getGroupCacheFile(normalizedRoot, userMd5)
|
||||
const scheduled = readScheduledValue<CachedGroupSnapshotFile>(file)
|
||||
if (scheduled) return scheduled
|
||||
const memory = groupMemory.get(file)
|
||||
if (memory) {
|
||||
touchMemory(groupMemory, file, memory, MAX_MEMORY_GROUP_SNAPSHOTS)
|
||||
return memory
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<CachedGroupSnapshotFile>
|
||||
if (!isCurrentAccountFile(raw, normalizedRoot) || raw.userMd5 !== userMd5 || !raw.snapshot) {
|
||||
return null
|
||||
}
|
||||
const result: CachedGroupSnapshotFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
userMd5,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
snapshot: raw.snapshot
|
||||
}
|
||||
touchMemory(groupMemory, file, result, MAX_MEMORY_GROUP_SNAPSHOTS)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] group snapshot read failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneCacheDirectory(directory: string, maxFiles: number): Promise<void> {
|
||||
const now = Date.now()
|
||||
if (now - (lastPrunedAt.get(directory) || 0) < PRUNE_INTERVAL_MS) return
|
||||
lastPrunedAt.set(directory, now)
|
||||
|
||||
try {
|
||||
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.json'))
|
||||
if (names.length <= maxFiles) return
|
||||
const entries = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const file = path.join(directory, name)
|
||||
const stat = await fs.stat(file)
|
||||
return { file, modifiedAt: stat.mtimeMs }
|
||||
})
|
||||
)
|
||||
const expired = entries
|
||||
.sort((left, right) => right.modifiedAt - left.modifiedAt)
|
||||
.slice(maxFiles)
|
||||
await Promise.all(
|
||||
expired.map(async ({ file }) => {
|
||||
messageMemory.delete(file)
|
||||
groupMemory.delete(file)
|
||||
await fs.remove(file)
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn('[BootstrapCache] prune failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueWrite(file: string): void {
|
||||
const scheduled = scheduledWrites.get(file)
|
||||
if (!scheduled) return
|
||||
const previous = writeQueues.get(file) || Promise.resolve()
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const current = scheduledWrites.get(file)
|
||||
if (
|
||||
!current ||
|
||||
current.revision !== scheduled.revision ||
|
||||
current.generation !== cacheGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const tempFile = `${file}.${process.pid}.${scheduled.revision}.tmp`
|
||||
await fs.ensureDir(path.dirname(file))
|
||||
await fs.writeFile(tempFile, JSON.stringify(current.value), 'utf8')
|
||||
const latest = scheduledWrites.get(file)
|
||||
if (
|
||||
!latest ||
|
||||
latest.revision !== scheduled.revision ||
|
||||
latest.generation !== cacheGeneration
|
||||
) {
|
||||
await fs.remove(tempFile)
|
||||
return
|
||||
}
|
||||
await fs.move(tempFile, file, { overwrite: true })
|
||||
const completed = scheduledWrites.get(file)
|
||||
if (
|
||||
completed?.revision === scheduled.revision &&
|
||||
completed.generation === scheduled.generation
|
||||
) {
|
||||
scheduledWrites.delete(file)
|
||||
}
|
||||
if (current.cleanupFile) await fs.remove(current.cleanupFile)
|
||||
if (current.prune) {
|
||||
void pruneCacheDirectory(current.prune.directory, current.prune.maxFiles)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (writeQueues.get(file) === next) writeQueues.delete(file)
|
||||
})
|
||||
writeQueues.set(file, next)
|
||||
}
|
||||
|
||||
function scheduleWrite(
|
||||
file: string,
|
||||
value: unknown,
|
||||
options?: { cleanupFile?: string; prune?: { directory: string; maxFiles: number } }
|
||||
): void {
|
||||
const existingTimer = writeTimers.get(file)
|
||||
if (existingTimer) clearTimeout(existingTimer)
|
||||
const revision = (writeRevisions.get(file) || 0) + 1
|
||||
writeRevisions.set(file, revision)
|
||||
scheduledWrites.set(file, {
|
||||
value,
|
||||
revision,
|
||||
generation: cacheGeneration,
|
||||
cleanupFile: options?.cleanupFile,
|
||||
prune: options?.prune
|
||||
})
|
||||
writeTimers.set(
|
||||
file,
|
||||
setTimeout(() => {
|
||||
writeTimers.delete(file)
|
||||
queueWrite(file)
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function loadOrCreateStartupCache(accountRoot: string): StartupCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const existing = readStartupCacheFile(normalizedRoot)
|
||||
if (existing) return existing
|
||||
const created: StartupCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: []
|
||||
}
|
||||
startupMemory.set(getAccountCachePaths(normalizedRoot).startup, created)
|
||||
return created
|
||||
}
|
||||
|
||||
function writeStartupCache(cache: StartupCacheFile): void {
|
||||
const paths = getAccountCachePaths(cache.accountRoot)
|
||||
startupMemory.set(paths.startup, cache)
|
||||
scheduleWrite(paths.startup, cache, { cleanupFile: paths.legacy })
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
export function getBootstrapCache(accountRoot?: string): {
|
||||
@@ -148,11 +409,11 @@ export function getBootstrapCache(accountRoot?: string): {
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const cache = readStartupCacheFile(normalizeRoot(accountRoot))
|
||||
if (!cache) return null
|
||||
return {
|
||||
self: cache.self,
|
||||
contacts: cache.contacts || [],
|
||||
contacts: cache.contacts,
|
||||
updatedAt: cache.updatedAt
|
||||
}
|
||||
}
|
||||
@@ -168,8 +429,8 @@ function isRawContactName(contact: Contact): boolean {
|
||||
}
|
||||
|
||||
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
if (!cache?.contacts?.length) return contacts
|
||||
const cache = readStartupCacheFile(accountRoot)
|
||||
if (!cache?.contacts.length) return contacts
|
||||
const avatarByUsername = new Map(
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.avatar)
|
||||
@@ -197,23 +458,23 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
|
||||
}
|
||||
|
||||
export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
const cache = loadOrCreateStartupCache(accountRoot)
|
||||
if (!cache) return
|
||||
cache.self = self
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
writeStartupCache(cache)
|
||||
}
|
||||
|
||||
export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
const cache = loadOrCreateStartupCache(accountRoot)
|
||||
if (!cache) return
|
||||
const avatarByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.avatar)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
cache.contacts
|
||||
.filter(
|
||||
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||
)
|
||||
@@ -231,12 +492,12 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
|
||||
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
|
||||
}))
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
writeStartupCache(cache)
|
||||
}
|
||||
|
||||
export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache || !cache.contacts?.length) return
|
||||
const cache = loadOrCreateStartupCache(accountRoot)
|
||||
if (!cache?.contacts.length) return
|
||||
let changed = false
|
||||
cache.contacts = cache.contacts.map((contact) => {
|
||||
const avatar = avatars[contact.m_nsUsrName]
|
||||
@@ -246,7 +507,7 @@ export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<strin
|
||||
})
|
||||
if (!changed) return
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
writeStartupCache(cache)
|
||||
}
|
||||
|
||||
export function getCachedMessages(
|
||||
@@ -255,9 +516,47 @@ export function getCachedMessages(
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Message[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)]
|
||||
return bucket?.items || []
|
||||
return (
|
||||
readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))?.items || []
|
||||
)
|
||||
}
|
||||
|
||||
export function getCachedMessagePage(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
|
||||
const bucket = readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))
|
||||
const messages = bucket?.items || []
|
||||
return {
|
||||
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(messages),
|
||||
messages,
|
||||
groupSnapshot: readGroupSnapshotFile(accountRoot, userMd5)?.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCachedGroupSnapshot(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
snapshot: CachedGroupSnapshot
|
||||
): void {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot || !userMd5) return
|
||||
const paths = getAccountCachePaths(normalizedRoot)
|
||||
const file = getGroupCacheFile(normalizedRoot, userMd5)
|
||||
const value: CachedGroupSnapshotFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
userMd5,
|
||||
updatedAt: Date.now(),
|
||||
snapshot
|
||||
}
|
||||
touchMemory(groupMemory, file, value, MAX_MEMORY_GROUP_SNAPSHOTS)
|
||||
scheduleWrite(file, value, {
|
||||
prune: { directory: paths.groups, maxFiles: MAX_GROUP_SNAPSHOTS }
|
||||
})
|
||||
}
|
||||
|
||||
export function saveCachedMessages(
|
||||
@@ -267,17 +566,52 @@ export function saveCachedMessages(
|
||||
endTime: number | undefined,
|
||||
messages: Message[]
|
||||
): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
const nextMessages = cache.messages || {}
|
||||
nextMessages[messageBucketKey(userMd5, startTime, endTime)] = {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot || !userMd5) return
|
||||
const cacheKey = messageBucketKey(userMd5, startTime, endTime)
|
||||
const paths = getAccountCachePaths(normalizedRoot)
|
||||
const file = getMessageCacheFile(normalizedRoot, cacheKey)
|
||||
const value: CachedMessageBucketFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
cacheKey,
|
||||
updatedAt: Date.now(),
|
||||
startTime,
|
||||
endTime,
|
||||
items: messages.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||
}
|
||||
pruneMessageBuckets(nextMessages)
|
||||
cache.messages = nextMessages
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
touchMemory(messageMemory, file, value, MAX_MEMORY_MESSAGE_BUCKETS)
|
||||
scheduleWrite(file, value, {
|
||||
prune: { directory: paths.messages, maxFiles: MAX_MESSAGE_BUCKETS }
|
||||
})
|
||||
}
|
||||
|
||||
export function flushBootstrapCacheWritesSync(): void {
|
||||
for (const timer of writeTimers.values()) clearTimeout(timer)
|
||||
writeTimers.clear()
|
||||
const writes = Array.from(scheduledWrites.entries())
|
||||
scheduledWrites.clear()
|
||||
for (const [file, scheduled] of writes) {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(file))
|
||||
fs.writeFileSync(file, JSON.stringify(scheduled.value), 'utf8')
|
||||
if (scheduled.cleanupFile) fs.removeSync(scheduled.cleanupFile)
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] flush failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearBootstrapCache(): void {
|
||||
cacheGeneration += 1
|
||||
for (const timer of writeTimers.values()) clearTimeout(timer)
|
||||
writeTimers.clear()
|
||||
scheduledWrites.clear()
|
||||
writeQueues.clear()
|
||||
writeRevisions.clear()
|
||||
lastPrunedAt.clear()
|
||||
startupMemory.clear()
|
||||
messageMemory.clear()
|
||||
groupMemory.clear()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,10 @@ export interface FormattedContact {
|
||||
md5: string
|
||||
type: 'user' | 'group'
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
isFolded?: boolean
|
||||
isMuted?: boolean
|
||||
}
|
||||
|
||||
export interface FormattedMessage {
|
||||
@@ -48,9 +54,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,48 +140,68 @@ 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,
|
||||
isFolded: user.isFolded,
|
||||
isMuted: user.isMuted
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function getContactAvatars(usernames: string[]): Record<string, string> {
|
||||
export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> {
|
||||
if (!dbRef) return []
|
||||
await dbRef.getWcdb4Client().getSessionsAsync({
|
||||
// macOS session rows frequently contain only wxid/chatroom ids. Hydrate
|
||||
// contact display names before exposing the list to the renderer.
|
||||
hydrateDisplayNames: true,
|
||||
hydrateStatuses: true
|
||||
})
|
||||
return listContacts(filter)
|
||||
}
|
||||
|
||||
export async function getContactAvatars(usernames: string[]): Promise<Record<string, string>> {
|
||||
if (!dbRef) return {}
|
||||
const normalized = Array.from(
|
||||
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (normalized.length === 0) return {}
|
||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
||||
return dbRef.getWcdb4Client().getAvatarUrlsAsync(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 +212,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,26 +251,81 @@ 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|<appmsg\b|<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
|
||||
} else if (parsed.type !== 'unknown') {
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
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 +335,22 @@ 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 === 'forwardBundle') displayType = '合并转发'
|
||||
if (parsed.type === 'unknown') {
|
||||
displayType = '不支持的消息'
|
||||
contentData = { ...parsed, messageType: msgType }
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -262,10 +368,20 @@ export function listMessages(
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentData && !MSG_TYPE_DICT[msgType] && msgType !== 0) {
|
||||
contentData = { type: 'unknown', raw: rawContent, messageType: msgType }
|
||||
content = ''
|
||||
displayType = '不支持的消息'
|
||||
}
|
||||
|
||||
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 +392,9 @@ export function listMessages(
|
||||
senderId,
|
||||
sessionId: username,
|
||||
localId,
|
||||
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
|
||||
createTime,
|
||||
recoveredFromRecallJournal,
|
||||
contentData
|
||||
}
|
||||
})
|
||||
@@ -287,6 +405,33 @@ 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 +453,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)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { execFile } from 'child_process'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { isUsableDbRoot } from './settings-store'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const platformLabel = (): string => {
|
||||
if (process.platform === 'win32') return `Windows ${os.release()} (${process.arch})`
|
||||
if (process.platform === 'darwin') return `macOS ${os.release()} (${process.arch})`
|
||||
return `${process.platform} ${os.release()} (${process.arch})`
|
||||
}
|
||||
|
||||
async function detectWindowsWechatVersion(): Promise<string> {
|
||||
const script = [
|
||||
'$process = Get-Process Weixin,WeChat -ErrorAction SilentlyContinue | Where-Object Path | Select-Object -First 1',
|
||||
'$candidate = if ($process) { $process.Path } else {',
|
||||
" @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } | ForEach-Object { Join-Path $_ 'Tencent\\WeChat\\WeChat.exe' } | Where-Object { Test-Path $_ } | Select-Object -First 1",
|
||||
'}',
|
||||
'if ($candidate) { (Get-Item -LiteralPath $candidate).VersionInfo.ProductVersion }'
|
||||
].join('; ')
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-Command', script],
|
||||
{ timeout: 3000, windowsHide: true }
|
||||
)
|
||||
return stdout.trim() || '未检测到'
|
||||
} catch {
|
||||
return '未检测到'
|
||||
}
|
||||
}
|
||||
|
||||
async function detectMacWechatVersion(): Promise<string> {
|
||||
const candidates = [
|
||||
'/Applications/WeChat.app/Contents/Info',
|
||||
path.join(os.homedir(), 'Applications/WeChat.app/Contents/Info')
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(`${candidate}.plist`)) continue
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/defaults',
|
||||
['read', candidate, 'CFBundleShortVersionString'],
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
if (stdout.trim()) return stdout.trim()
|
||||
} catch {
|
||||
// Continue to the next known installation location.
|
||||
}
|
||||
}
|
||||
return '未检测到'
|
||||
}
|
||||
|
||||
export async function detectWechatVersion(): Promise<string> {
|
||||
if (process.platform === 'win32') return detectWindowsWechatVersion()
|
||||
if (process.platform === 'darwin') return detectMacWechatVersion()
|
||||
return '未检测到'
|
||||
}
|
||||
|
||||
export function detectDataStructureVersion(dbRoot: string): string {
|
||||
return isUsableDbRoot(dbRoot) ? '微信 4.x(WCDB)' : '未检测到'
|
||||
}
|
||||
|
||||
export function getOsVersionLabel(): string {
|
||||
return platformLabel()
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
ImageResourceCheck,
|
||||
TestImageDecryptionRequest
|
||||
} from '../../shared/image-decryption'
|
||||
import { ImageDecryptService } from '../image-decrypt-service'
|
||||
import { ImageDecryptService, inspectImageDecoderStatus } from '../image-decrypt-service'
|
||||
import * as chat from './chat-service'
|
||||
import { validateImageKeyRequest } from './image-key-config-service'
|
||||
import { isWechatRunning } from './wechat-process-status'
|
||||
@@ -17,12 +17,18 @@ 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')) ||
|
||||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
|
||||
const dbConnected = chat.isReady()
|
||||
const [wechatRunning, decoder] = await Promise.all([
|
||||
isWechatRunning(),
|
||||
inspectImageDecoderStatus()
|
||||
])
|
||||
|
||||
return {
|
||||
configured: config.configured,
|
||||
@@ -34,9 +40,10 @@ export async function inspectImageDecryptionStatus(
|
||||
updatedAt: config.updatedAt,
|
||||
platform: process.platform,
|
||||
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
|
||||
wechatRunning: await isWechatRunning(),
|
||||
wechatRunning,
|
||||
accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid),
|
||||
cacheState: canUseCacheRoot() ? 'normal' : 'unavailable',
|
||||
decoder,
|
||||
resources: {
|
||||
imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'),
|
||||
imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'),
|
||||
@@ -65,7 +72,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 +84,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@ export interface AppSettings {
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
imageKeyFallbackDisabled: boolean
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
}
|
||||
|
||||
function getDefaultDbRoot(): string {
|
||||
@@ -25,21 +46,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)
|
||||
}
|
||||
|
||||
@@ -66,7 +86,7 @@ function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values))
|
||||
}
|
||||
|
||||
function isUsableDbRoot(candidate?: string): boolean {
|
||||
export function isUsableDbRoot(candidate?: string): boolean {
|
||||
if (!candidate || !fs.existsSync(candidate)) return false
|
||||
if (fs.existsSync(path.join(candidate, 'db_storage'))) return true
|
||||
try {
|
||||
@@ -78,6 +98,21 @@ function isUsableDbRoot(candidate?: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDbRoot(candidate?: string): { valid: boolean; error?: string } {
|
||||
const root = String(candidate || '').trim()
|
||||
if (!root) return { valid: false, error: '微信数据目录为空,请重新选择目录' }
|
||||
if (!fs.existsSync(root)) {
|
||||
return { valid: false, error: '微信数据目录不存在,请检查路径或重新选择目录' }
|
||||
}
|
||||
if (!isUsableDbRoot(root)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '所选目录中未找到微信 4.x 数据库(db_storage),请选择 xwechat_files 或账号目录'
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const defaultDbRoot = getDefaultDbRoot()
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -89,12 +124,18 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false,
|
||||
ffmpegPath: '',
|
||||
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 +164,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_files(V4)。
|
||||
if (cache.imageKeyRoot) {
|
||||
cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot)
|
||||
}
|
||||
if (cache.dbRoot) {
|
||||
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -5,8 +5,15 @@ import https from 'https'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
import { classifyStickerHttpFailure, StickerFailureCode } from '../shared/sticker'
|
||||
|
||||
type StickerResult = { success: boolean; data?: string; error?: string }
|
||||
type StickerResult = {
|
||||
success: boolean
|
||||
data?: string
|
||||
error?: string
|
||||
failureCode?: StickerFailureCode
|
||||
httpStatus?: number
|
||||
}
|
||||
|
||||
const downloadCache = new Map<string, Promise<StickerResult>>()
|
||||
|
||||
@@ -21,6 +28,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 +51,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}`)
|
||||
@@ -121,15 +136,24 @@ export class StickerService {
|
||||
const redirectUrl = response.headers.location
|
||||
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
|
||||
const nextUrl = new URL(redirectUrl, url).toString()
|
||||
response.resume()
|
||||
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
const failure = classifyStickerHttpFailure(statusCode, url)
|
||||
response.resume()
|
||||
console.warn(
|
||||
`[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}`
|
||||
`[StickerService] download failed code=${failure.code} status=${statusCode} md5=${cacheKey} host=${this.getUrlHost(url)}`
|
||||
)
|
||||
resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` })
|
||||
resolve({
|
||||
success: false,
|
||||
error: failure.message,
|
||||
failureCode: failure.code,
|
||||
httpStatus: statusCode
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -190,6 +214,14 @@ export class StickerService {
|
||||
}
|
||||
}
|
||||
|
||||
private getUrlHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname || 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
private toDataUrl(buffer: Buffer, ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.gif': 'image/gif',
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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 readonly fileTokens = 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.createLocalMediaUrl(asset.filePath),
|
||||
poster: asset.posterPath ? this.createLocalMediaUrl(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
|
||||
}
|
||||
|
||||
pathForUrl(url: string): string | undefined {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'wxe-media:' || parsed.hostname !== 'local') return undefined
|
||||
return this.pathForToken(parsed.pathname.replace(/^\/+/, ''))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
createLocalMediaUrl(filePath: string): string {
|
||||
const normalizedPath = path.resolve(filePath)
|
||||
const existingToken = this.fileTokens.get(normalizedPath)
|
||||
if (existingToken && this.urlTokens.get(existingToken) === normalizedPath) {
|
||||
return `wxe-media://local/${existingToken}`
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(18).toString('hex')
|
||||
this.urlTokens.set(token, normalizedPath)
|
||||
this.fileTokens.set(normalizedPath, token)
|
||||
if (this.urlTokens.size > 2048) {
|
||||
const oldestToken = this.urlTokens.keys().next().value
|
||||
if (oldestToken) {
|
||||
const oldestPath = this.urlTokens.get(oldestToken)
|
||||
this.urlTokens.delete(oldestToken)
|
||||
if (oldestPath) this.fileTokens.delete(oldestPath)
|
||||
}
|
||||
}
|
||||
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,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',
|
||||
|
||||
@@ -4,6 +4,10 @@ export interface UserContact {
|
||||
m_nsUsrName: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
isFolded?: boolean
|
||||
isMuted?: boolean
|
||||
}
|
||||
|
||||
export interface WechatMessage {
|
||||
@@ -33,20 +37,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 +52,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 +80,11 @@ 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,
|
||||
isFolded: session.isFolded,
|
||||
isMuted: session.isMuted
|
||||
}))
|
||||
.filter((contact) => {
|
||||
if (!keyword) return true
|
||||
@@ -109,6 +119,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 +135,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 +162,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 +171,19 @@ 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
import type {
|
||||
DatabaseKeyEnvironment,
|
||||
DatabaseKeyStorageResult,
|
||||
DatabaseKeyValidationResult
|
||||
DatabaseKeyValidationResult,
|
||||
AccountDiscoveryResult
|
||||
} from '../shared/database-key'
|
||||
import type {
|
||||
ImageDecoderSelectionResult,
|
||||
ImageDecoderStatus,
|
||||
ImageDecryptionStatus,
|
||||
ImageDecryptionTestResult,
|
||||
ImageKeyConfigResult,
|
||||
@@ -39,15 +42,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,14 +108,28 @@ 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
|
||||
key: string,
|
||||
accountRoot: string
|
||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||
discoverAccounts: (inputPath: string) => Promise<AccountDiscoveryResult>
|
||||
getBootstrapCache: () => Promise<{
|
||||
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
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 +137,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 +210,7 @@ declare global {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
@@ -151,10 +218,23 @@ 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 }>
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
error?: string
|
||||
failureCode?: import('../shared/sticker').StickerFailureCode
|
||||
httpStatus?: number
|
||||
}>
|
||||
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: (
|
||||
@@ -162,14 +242,17 @@ declare global {
|
||||
) => Promise<SaveGeneratedReportResult>
|
||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
getSavedDbKey: () => Promise<DatabaseKeyStorageResult>
|
||||
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
|
||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||
readDatabaseKeyClipboard: () => Promise<{
|
||||
success: boolean
|
||||
value?: string
|
||||
error?: string
|
||||
}>
|
||||
autoGetDbKey: (options?: { save?: boolean }) => Promise<{
|
||||
autoGetDbKey: (
|
||||
accountRoot: string,
|
||||
options?: { save?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
key?: string
|
||||
error?: string
|
||||
@@ -191,22 +274,33 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
}>
|
||||
getImageKeyConfig: () => Promise<ImageKeyConfigResult>
|
||||
getImageDecryptionStatus: () => Promise<ImageDecryptionStatus>
|
||||
selectImageDecoder: () => Promise<ImageDecoderSelectionResult>
|
||||
getImageDecoderStatus: () => Promise<ImageDecoderStatus>
|
||||
openImageDecoderDownload: () => Promise<{ success: boolean; error?: string }>
|
||||
saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult>
|
||||
testImageDecryption: (
|
||||
request: TestImageDecryptionRequest
|
||||
) => Promise<ImageDecryptionTestResult>
|
||||
clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }>
|
||||
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult>
|
||||
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
||||
pasteAndSaveDbKey: (
|
||||
accountRoot: string
|
||||
) => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
saveDbKey: (accountRoot: string, key: string) => Promise<DatabaseKeyStorageResult>
|
||||
clearSavedDbKey: (accountRoot: string) => Promise<{ success: boolean; error?: string }>
|
||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
@@ -217,8 +311,14 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
@@ -231,8 +331,14 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}>
|
||||
@@ -243,8 +349,14 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
|
||||
@@ -17,18 +17,41 @@ 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'
|
||||
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
||||
import type { AccountDiscoveryResult } from '../shared/database-key'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
|
||||
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
|
||||
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||
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, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
|
||||
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
|
||||
ipcRenderer.invoke('accounts:discover', inputPath),
|
||||
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 +80,19 @@ const api = {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
) => 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'),
|
||||
@@ -68,20 +101,29 @@ const api = {
|
||||
deleteGeneratedReport: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
||||
getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
|
||||
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
||||
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
||||
autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options),
|
||||
autoGetDbKey: (accountRoot: string, options?: { save?: boolean }) =>
|
||||
ipcRenderer.invoke('key:autoGetDbKey', accountRoot, options),
|
||||
autoGetImageKey: (options?: { save?: boolean }) =>
|
||||
ipcRenderer.invoke('key:autoGetImageKey', options),
|
||||
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
|
||||
getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'),
|
||||
selectImageDecoder: (): Promise<ImageDecoderSelectionResult> =>
|
||||
ipcRenderer.invoke('image:selectDecoder'),
|
||||
getImageDecoderStatus: (): Promise<ImageDecoderStatus> =>
|
||||
ipcRenderer.invoke('image:getDecoderStatus'),
|
||||
openImageDecoderDownload: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('image:openDecoderDownload'),
|
||||
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
|
||||
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
|
||||
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
|
||||
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
||||
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
|
||||
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
||||
pasteAndSaveDbKey: (accountRoot: string) =>
|
||||
ipcRenderer.invoke('key:pasteAndSaveDbKey', accountRoot),
|
||||
saveDbKey: (accountRoot: string, key: string) =>
|
||||
ipcRenderer.invoke('key:saveDbKey', accountRoot, key),
|
||||
clearSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:clearSavedDbKey', accountRoot),
|
||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -4,71 +4,38 @@ import { ChatHeader } from './chat/ChatHeader'
|
||||
import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||
import { DataTrustBar } from './chat/DataTrustBar'
|
||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||
import { ExportRange } from './chat/ExportMenu'
|
||||
import { MessageList } from './chat/MessageList'
|
||||
|
||||
interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
messages: Message[]
|
||||
isLoadingMessages?: boolean
|
||||
messageHistoryStatus?: 'idle' | 'end' | 'error'
|
||||
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> = ({
|
||||
contact,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
messageHistoryStatus,
|
||||
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 +52,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 +63,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 +146,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 +174,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 +187,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} />
|
||||
@@ -268,18 +207,23 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
messages={filteredMessages}
|
||||
hiddenMessageCount={0}
|
||||
isLoadingMessages={isLoadingMessages}
|
||||
messageHistoryStatus={messageHistoryStatus}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import React from 'react'
|
||||
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
|
||||
|
||||
const GUIDE_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
|
||||
export type DatabaseConnectionMode = 'automatic' | 'manual'
|
||||
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
|
||||
@@ -10,6 +14,11 @@ interface DatabaseConnectionPageProps {
|
||||
dbRoot: string
|
||||
showDbKey: boolean
|
||||
isFetching: boolean
|
||||
isConnecting: boolean
|
||||
guideStep: 1 | 2 | 3 | 4 | 5 | 6
|
||||
environment?: DatabaseKeyEnvironment
|
||||
accounts: WechatAccountCandidate[]
|
||||
selectedAccountId: string
|
||||
status: string
|
||||
statusKind: DatabaseConnectionStatusKind
|
||||
showMacKeyFaq: boolean
|
||||
@@ -17,8 +26,16 @@ interface DatabaseConnectionPageProps {
|
||||
onModeChange: (mode: DatabaseConnectionMode) => void
|
||||
onDbKeyChange: (value: string) => void
|
||||
onDbRootChange: (value: string) => void
|
||||
onSelectAccount: (account: WechatAccountCandidate) => void
|
||||
onSelectDbRoot: () => void
|
||||
onToggleDbKey: () => void
|
||||
onAutoGetKey: () => void
|
||||
onRefreshEnvironment: () => void
|
||||
onGuideNext: () => void
|
||||
onGuideBack: () => void
|
||||
onGuideCancel: () => void
|
||||
onValidateConnection: () => void
|
||||
onCopyDiagnostics: () => void
|
||||
onManualConnect: () => void
|
||||
onPasteKey: () => void
|
||||
onClearKey: () => void
|
||||
@@ -89,6 +106,11 @@ export function DatabaseConnectionPage({
|
||||
dbRoot,
|
||||
showDbKey,
|
||||
isFetching,
|
||||
isConnecting,
|
||||
guideStep,
|
||||
environment,
|
||||
accounts = [],
|
||||
selectedAccountId = '',
|
||||
status,
|
||||
statusKind,
|
||||
showMacKeyFaq,
|
||||
@@ -96,8 +118,16 @@ export function DatabaseConnectionPage({
|
||||
onModeChange,
|
||||
onDbKeyChange,
|
||||
onDbRootChange,
|
||||
onSelectAccount,
|
||||
onSelectDbRoot,
|
||||
onToggleDbKey,
|
||||
onAutoGetKey,
|
||||
onRefreshEnvironment,
|
||||
onGuideNext,
|
||||
onGuideBack,
|
||||
onGuideCancel,
|
||||
onValidateConnection,
|
||||
onCopyDiagnostics,
|
||||
onManualConnect,
|
||||
onPasteKey,
|
||||
onClearKey
|
||||
@@ -116,9 +146,9 @@ export function DatabaseConnectionPage({
|
||||
<LineIcon name="database" />
|
||||
</div>
|
||||
<h1>WechatExplorer</h1>
|
||||
<p className="database-login-tagline">你的本地微信聊天档案</p>
|
||||
<p className="database-login-tagline">让 AI 读懂你的微信</p>
|
||||
<p className="database-login-description">
|
||||
连接本机微信数据库,开始检索、整理和分析聊天记录。
|
||||
连接成功后,你可以搜索聊天记录、生成群聊日报,并按需使用 AI 分析。
|
||||
</p>
|
||||
<div className="database-login-promises">
|
||||
<div>
|
||||
@@ -131,7 +161,7 @@ export function DatabaseConnectionPage({
|
||||
</div>
|
||||
<div>
|
||||
<LineIcon name="cloud" />
|
||||
<span>不会上传</span>
|
||||
<span>AI 按需启用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,6 +170,42 @@ export function DatabaseConnectionPage({
|
||||
|
||||
<section className="database-login-workspace" aria-label="数据库连接">
|
||||
<div className="database-login-panel">
|
||||
<div className="database-login-start">
|
||||
<p className="database-login-eyebrow">第一次使用</p>
|
||||
<h2>开始连接微信</h2>
|
||||
<p>按顺序检查环境、准备连接组件并验证数据库,通常几分钟即可完成。</p>
|
||||
<ol>
|
||||
<li>
|
||||
<span>1</span>
|
||||
<div>
|
||||
<strong>检查本机环境</strong>
|
||||
<small>确认微信版本、数据目录和运行状态</small>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>2</span>
|
||||
<div>
|
||||
<strong>准备连接组件</strong>
|
||||
<small>页面会按当前系统给出对应步骤</small>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>3</span>
|
||||
<div>
|
||||
<strong>登录并验证连接</strong>
|
||||
<small>验证通过后进入主界面</small>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<a
|
||||
className="database-login-guide-link"
|
||||
href={GUIDE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
查看 5 分钟上手教程 →
|
||||
</a>
|
||||
</div>
|
||||
<div className="database-login-tabs" role="tablist" aria-label="连接方式">
|
||||
<button
|
||||
type="button"
|
||||
@@ -148,21 +214,26 @@ export function DatabaseConnectionPage({
|
||||
className={mode === 'automatic' ? 'active' : ''}
|
||||
onClick={() => onModeChange('automatic')}
|
||||
>
|
||||
自动获取
|
||||
开始连接
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'manual'}
|
||||
className={mode === 'manual' ? 'active' : ''}
|
||||
className={`database-login-manual-tab ${mode === 'manual' ? 'active' : ''}`}
|
||||
onClick={() => onModeChange('manual')}
|
||||
>
|
||||
手动输入
|
||||
高级用户:已有数据库密钥?手动连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'automatic' ? (
|
||||
<div className="database-login-auto" role="tabpanel">
|
||||
<div className="database-login-guide-progress" aria-label={`连接进度 ${guideStep}/6`}>
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<span key={index} className={index + 1 <= guideStep ? 'active' : ''} />
|
||||
))}
|
||||
</div>
|
||||
<div className={`database-login-state-card ${statusKind}`}>
|
||||
<div className="database-login-state-heading">
|
||||
<span className="database-login-state-icon">
|
||||
@@ -170,69 +241,229 @@ export function DatabaseConnectionPage({
|
||||
</span>
|
||||
<div>
|
||||
<strong>
|
||||
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
|
||||
{statusKind === 'error'
|
||||
? '当前步骤未完成'
|
||||
: [
|
||||
'检查本机环境',
|
||||
'让微信停在登录页面',
|
||||
'确认开始准备',
|
||||
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
|
||||
'现在可以登录微信',
|
||||
'验证数据库连接'
|
||||
][guideStep - 1]}
|
||||
</strong>
|
||||
<p>
|
||||
{statusKind === 'error'
|
||||
? status
|
||||
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
|
||||
: status ||
|
||||
[
|
||||
'确认下方检测结果;没有找到目录时可以手动选择。',
|
||||
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
|
||||
'开始后请按页面提示完成系统授权。',
|
||||
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
|
||||
'请回到微信完成登录,登录成功后再回来验证。',
|
||||
'正在验证密钥和本地数据库,请稍候。'
|
||||
][guideStep - 1]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="database-login-diagnostics">
|
||||
<div>
|
||||
<dt>微信客户端</dt>
|
||||
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
存储路径
|
||||
<StoragePathHelp />
|
||||
</dt>
|
||||
<dd>
|
||||
<span className="database-login-path-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
aria-label="微信数据存储路径"
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<span className="database-login-path-value" role="status">
|
||||
{dbRoot || defaultPath}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>数据库状态</dt>
|
||||
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{guideStep === 1 && (
|
||||
<>
|
||||
<dl className="database-login-diagnostics">
|
||||
<div>
|
||||
<dt>操作系统</dt>
|
||||
<dd>{environment?.osVersion || (isMac ? 'macOS' : 'Windows')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>微信版本</dt>
|
||||
<dd>{environment?.wechatVersion || '未检测到'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>数据结构</dt>
|
||||
<dd>{environment?.dataStructureVersion || '未检测到'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
存储路径
|
||||
<StoragePathHelp />
|
||||
</dt>
|
||||
<dd>
|
||||
<span className="database-login-path-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
aria-label="微信数据存储路径"
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<span className="database-login-path-value" role="status">
|
||||
{dbRoot || defaultPath}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-path-select"
|
||||
onClick={onSelectDbRoot}
|
||||
disabled={isFetching || isConnecting}
|
||||
>
|
||||
选择目录
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>微信状态</dt>
|
||||
<dd>{environment?.wechatRunning ? '运行中' : '未检测到'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{accounts.length > 0 && (
|
||||
<section className="database-account-list" aria-label="选择微信账号">
|
||||
<h3>选择微信账号</h3>
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
type="button"
|
||||
key={account.id}
|
||||
className={`database-account-card ${selectedAccountId === account.id ? 'selected' : ''}`}
|
||||
aria-pressed={selectedAccountId === account.id}
|
||||
onClick={() => onSelectAccount(account)}
|
||||
>
|
||||
<span className="database-account-avatar">
|
||||
{account.avatar ? (
|
||||
<img src={account.avatar} alt="" />
|
||||
) : (
|
||||
(account.nickname || '?').charAt(0)
|
||||
)}
|
||||
</span>
|
||||
<span className="database-account-identity">
|
||||
<strong>{account.nickname || '昵称未识别'}</strong>
|
||||
<small>{account.wxid || 'wxid 未识别'}</small>
|
||||
<code title={account.accountRoot}>{account.accountRoot}</code>
|
||||
</span>
|
||||
<span className="database-account-status">
|
||||
{account.hasSavedDbKey ? '已有可用密钥' : '尚无可用密钥'}
|
||||
<small>
|
||||
{account.loginStatus === 'current'
|
||||
? '当前已连接账号'
|
||||
: account.loginStatus === 'other'
|
||||
? '非当前账号'
|
||||
: '登录状态未确认'}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-secondary"
|
||||
onClick={onSelectDbRoot}
|
||||
disabled={isFetching || isConnecting}
|
||||
>
|
||||
选择其他账号
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onAutoGetKey}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching
|
||||
? '正在获取密钥…'
|
||||
: statusKind === 'error'
|
||||
? '重新检测'
|
||||
: '自动获取密钥'}
|
||||
</button>
|
||||
{showMacKeyFaq && (
|
||||
{guideStep === 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onGuideNext}
|
||||
disabled={!selectedAccountId}
|
||||
>
|
||||
检查完成,继续
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-secondary"
|
||||
onClick={onRefreshEnvironment}
|
||||
>
|
||||
重新检查环境
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-text-action"
|
||||
onClick={onCopyDiagnostics}
|
||||
>
|
||||
复制脱敏诊断摘要
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{guideStep === 2 && (
|
||||
<button type="button" className="database-login-primary" onClick={onGuideNext}>
|
||||
我已准备好
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 3 && (
|
||||
<button type="button" className="database-login-primary" onClick={onAutoGetKey}>
|
||||
开始准备连接组件
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 4 && (
|
||||
<button type="button" className="database-login-primary" disabled>
|
||||
正在准备连接组件…
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onValidateConnection}
|
||||
disabled={!dbKey || isConnecting}
|
||||
>
|
||||
{isConnecting ? '正在验证…' : '微信已登录,验证连接'}
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 6 && (
|
||||
<button type="button" className="database-login-primary" disabled>
|
||||
正在验证数据库…
|
||||
</button>
|
||||
)}
|
||||
{guideStep > 1 && !isFetching && !isConnecting && (
|
||||
<div className="database-login-guide-actions">
|
||||
<button type="button" onClick={onGuideBack}>
|
||||
返回上一步
|
||||
</button>
|
||||
<button type="button" onClick={onGuideCancel}>
|
||||
取消并重新检查
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(isFetching || isConnecting) && (
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-text-action"
|
||||
onClick={onGuideCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<p className="database-login-platform-note">
|
||||
{isMac ? (
|
||||
<>
|
||||
macOS 首次获取密钥需要关闭 SIP。{' '}
|
||||
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
|
||||
查看说明
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
'Windows 已完整支持,不需要关闭 SIP。'
|
||||
)}
|
||||
</p>
|
||||
{showMacKeyFaq && isMac && (
|
||||
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
|
||||
查看详情 · 连接帮助
|
||||
获取失败?查看连接排查
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="database-login-manual" role="tabpanel">
|
||||
<p className="database-login-manual-note">
|
||||
仅适用于已经通过其他方式获得当前微信账号数据库密钥的高级用户。第一次使用请返回“开始连接”。
|
||||
</p>
|
||||
<div className="database-login-field">
|
||||
<label htmlFor="database-login-key">数据库密钥</label>
|
||||
<div className="database-login-key-input">
|
||||
@@ -261,15 +492,21 @@ export function DatabaseConnectionPage({
|
||||
微信数据目录
|
||||
<StoragePathHelp />
|
||||
</label>
|
||||
<input
|
||||
id="database-login-root"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<div className="database-login-root-control">
|
||||
<input
|
||||
id="database-login-root"
|
||||
aria-label="微信数据目录"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<button type="button" onClick={onSelectDbRoot} disabled={isConnecting}>
|
||||
选择目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
||||
@@ -277,11 +514,25 @@ export function DatabaseConnectionPage({
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onManualConnect}
|
||||
disabled={!keyIsValid}
|
||||
disabled={!keyIsValid || isConnecting}
|
||||
>
|
||||
连接数据库
|
||||
{isConnecting ? '正在连接…' : '连接数据库'}
|
||||
</button>
|
||||
<button type="button" className="database-login-secondary" onClick={onPasteKey}>
|
||||
{isConnecting && (
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-text-action"
|
||||
onClick={onGuideCancel}
|
||||
>
|
||||
取消连接
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-secondary"
|
||||
onClick={onPasteKey}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
从剪贴板粘贴并安全保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react'
|
||||
|
||||
interface FirstUseWelcomeProps {
|
||||
onDismiss: () => void
|
||||
onOpenSearch: () => void
|
||||
onOpenReport: () => void
|
||||
onOpenAISettings: () => void
|
||||
}
|
||||
|
||||
const GUIDE_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
|
||||
export function FirstUseWelcome({
|
||||
onDismiss,
|
||||
onOpenSearch,
|
||||
onOpenReport,
|
||||
onOpenAISettings
|
||||
}: FirstUseWelcomeProps): React.ReactElement {
|
||||
return (
|
||||
<div className="first-use-welcome-overlay" role="presentation">
|
||||
<section
|
||||
className="first-use-welcome"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="first-use-welcome-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="first-use-welcome-close"
|
||||
onClick={onDismiss}
|
||||
aria-label="关闭欢迎提示"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="first-use-welcome-mark" aria-hidden="true">
|
||||
✦
|
||||
</div>
|
||||
<p className="first-use-welcome-eyebrow">微信已连接</p>
|
||||
<h2 id="first-use-welcome-title">开始探索你的微信</h2>
|
||||
<p className="first-use-welcome-lead">
|
||||
最关键的一步已经完成。现在,让 AI 帮你看看最近的聊天都发生了什么。
|
||||
</p>
|
||||
|
||||
<button type="button" className="first-use-welcome-feature" onClick={onOpenReport}>
|
||||
<span className="first-use-welcome-feature-icon" aria-hidden="true">
|
||||
✦
|
||||
</span>
|
||||
<span className="first-use-welcome-feature-copy">
|
||||
<strong>试试 AI 群聊日报</strong>
|
||||
<small>选择一个群聊,看看最近聊了什么</small>
|
||||
</span>
|
||||
<span className="first-use-welcome-feature-arrow" aria-hidden="true">
|
||||
立即体验 →
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="first-use-welcome-secondary-actions">
|
||||
<button type="button" onClick={onDismiss}>
|
||||
查看聊天记录
|
||||
</button>
|
||||
<button type="button" onClick={onOpenSearch}>
|
||||
问问你的微信
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="first-use-welcome-footer">
|
||||
<span>还没有配置 AI?</span>
|
||||
<button type="button" onClick={onOpenAISettings}>
|
||||
配置 AI 模型
|
||||
</button>
|
||||
<a href={GUIDE_URL} target="_blank" rel="noreferrer">
|
||||
查看完整使用教程
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,55 +15,104 @@ 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 [usingFallback, setUsingFallback] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
if (imageUrl || loading) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
setError('缺少图片标识')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setError(null)
|
||||
} else {
|
||||
setError(result.error || '加载图片失败')
|
||||
}
|
||||
} catch {
|
||||
setError('加载图片失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (imageUrl || loading || error) return
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadImage = useCallback(
|
||||
async (priority = 0) => {
|
||||
if (imageUrl) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setError('缺少图片标识')
|
||||
return
|
||||
}
|
||||
if (loading) {
|
||||
if (priority === 0) {
|
||||
void requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
priority
|
||||
).catch(() => undefined)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
priority
|
||||
)
|
||||
if (!mountedRef.current) return
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(error instanceof Error ? error.message : '加载图片失败')
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false)
|
||||
}
|
||||
},
|
||||
[fallbackUrl, imageDatName, imageMd5, imageUrl, loading, sessionId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (imageUrl || error) return
|
||||
const element = containerRef.current
|
||||
if (!element || typeof IntersectionObserver === 'undefined') {
|
||||
const timer = window.setTimeout(() => void loadImage(), 0)
|
||||
const timer = window.setTimeout(() => void loadImage(0), 0)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return
|
||||
const entry = entries.find((candidate) => candidate.isIntersecting)
|
||||
if (!entry) return
|
||||
const rect = entry.boundingClientRect
|
||||
const isInViewport =
|
||||
rect.bottom >= 0 &&
|
||||
rect.top <= window.innerHeight &&
|
||||
rect.right >= 0 &&
|
||||
rect.left <= window.innerWidth
|
||||
observer.disconnect()
|
||||
void loadImage()
|
||||
void loadImage(isInViewport ? 0 : 1)
|
||||
},
|
||||
{ rootMargin: '400px 0px' }
|
||||
{ rootMargin: loading ? '0px' : '400px 0px' }
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
@@ -88,12 +139,10 @@ 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/') || result.data.startsWith('wxe-media://')) {
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setUsingFallback(false)
|
||||
setError(null)
|
||||
onImageClick?.(result.data)
|
||||
return
|
||||
@@ -109,7 +158,7 @@ export function ImageBubble({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="image-bubble image-loading" onClick={handleClick}>
|
||||
<div ref={containerRef} className="image-bubble image-loading" onClick={handleClick}>
|
||||
<div className="image-loading-skeleton" aria-hidden />
|
||||
<div className="image-quality-badge">加载中</div>
|
||||
</div>
|
||||
@@ -118,7 +167,7 @@ export function ImageBubble({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="image-bubble image-error" onClick={loadImage}>
|
||||
<div className="image-bubble image-error" onClick={() => void loadImage(0)}>
|
||||
<div className="image-error-text">{error || '图片未缓存'}</div>
|
||||
<div className="image-quality-badge">加载失败</div>
|
||||
</div>
|
||||
@@ -136,10 +185,12 @@ export function ImageBubble({
|
||||
|
||||
return (
|
||||
<div className="image-bubble image-loaded" onClick={handleClick}>
|
||||
<img src={imageUrl} alt="图片" className="image-content" />
|
||||
{(upgrading || isThumbnail) && (
|
||||
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
||||
)}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="图片"
|
||||
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
|
||||
/>
|
||||
{upgrading && <div className="image-quality-badge">正在查找原图</div>}
|
||||
<div className="image-actions">
|
||||
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
||||
复制
|
||||
|
||||
@@ -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,18 +24,29 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
|
||||
return <CardBubble data={contentData} />
|
||||
case 'share':
|
||||
return <ShareBubble data={contentData} />
|
||||
case 'forwardBundle':
|
||||
return <ForwardBundleBubble 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':
|
||||
return (
|
||||
<div className="message-text">
|
||||
{renderWechatEmojiText((contentData as { raw?: string }).raw || '[未知消息]')}
|
||||
<div className="unsupported-message">
|
||||
<strong>暂不支持此消息</strong>
|
||||
<span>
|
||||
消息类型 {(contentData as { messageType?: string | number }).messageType || '未知'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
@@ -36,6 +54,56 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
|
||||
}
|
||||
}
|
||||
|
||||
function ForwardBundleBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'forwardBundle' }>
|
||||
}): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const visibleItems = expanded ? data.items : data.items.slice(0, 3)
|
||||
const hiddenCount = Math.max(0, data.items.length - visibleItems.length)
|
||||
|
||||
return (
|
||||
<div className="forward-bundle-message">
|
||||
<button
|
||||
type="button"
|
||||
className="forward-bundle-header"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span>{data.title || '聊天记录'}</span>
|
||||
<small>
|
||||
{data.items.length ? `${data.items.length} 条消息` : data.description || '聊天记录'}
|
||||
</small>
|
||||
</button>
|
||||
<div className="forward-bundle-list">
|
||||
{visibleItems.length ? (
|
||||
visibleItems.map((item, index) => (
|
||||
<div
|
||||
className="forward-bundle-item"
|
||||
key={`${item.sender || ''}-${item.sentAt || ''}-${index}`}
|
||||
>
|
||||
{item.sender && <b>{item.sender}</b>}
|
||||
<span>{renderWechatEmojiText(item.text, 24)}</span>
|
||||
{item.nested?.length ? <small>包含 {item.nested.length} 条聊天记录</small> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="forward-bundle-empty">暂未解析到可展示的记录</div>
|
||||
)}
|
||||
</div>
|
||||
{(hiddenCount > 0 || expanded) && data.items.length > 3 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="forward-bundle-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? '收起' : `展开其余 ${hiddenCount} 条`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocationBubble({
|
||||
data
|
||||
}: {
|
||||
@@ -110,6 +178,78 @@ 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
|
||||
@@ -148,6 +288,7 @@ function StickerBubble({
|
||||
)
|
||||
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
|
||||
const [error, setError] = useState(false)
|
||||
const [errorText, setErrorText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey || displayUrl || error) return
|
||||
@@ -161,12 +302,17 @@ function StickerBubble({
|
||||
stickerDataUrlCache.set(cacheKey, result.data)
|
||||
setDisplayUrl(result.data)
|
||||
setError(false)
|
||||
setErrorText('')
|
||||
} else {
|
||||
setError(true)
|
||||
setErrorText(result.error || '表情包未缓存')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true)
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setErrorText('表情包加载失败')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
@@ -195,13 +341,21 @@ function StickerBubble({
|
||||
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
|
||||
<div className="sticker-placeholder">{error ? errorText || '表情包未缓存' : '表情包'}</div>
|
||||
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 +364,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>
|
||||
)
|
||||
}
|
||||
@@ -16,15 +16,16 @@ export function VoicePlayer({
|
||||
sessionId,
|
||||
localId,
|
||||
createTime,
|
||||
svrId
|
||||
svrId,
|
||||
duration
|
||||
}: VoicePlayerProps): JSX.Element {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [audioDuration, setAudioDuration] = useState<number | undefined>(undefined)
|
||||
const [shouldAutoPlay, setShouldAutoPlay] = useState(false)
|
||||
const [audioDuration, setAudioDuration] = useState<number | undefined>(duration)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const objectUrlRef = useRef<string | null>(null)
|
||||
|
||||
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
|
||||
if (globalCurrentAudio && globalCurrentAudio !== audio) {
|
||||
@@ -35,126 +36,113 @@ export function VoicePlayer({
|
||||
globalCurrentAudio = audio
|
||||
}, [])
|
||||
|
||||
const handlePlayPause = useCallback(async () => {
|
||||
// 如果还没有音频数据,先获取
|
||||
if (!audioUrl && !loading) {
|
||||
setLoading(true)
|
||||
setShouldAutoPlay(true)
|
||||
console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime })
|
||||
try {
|
||||
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
|
||||
console.log('[VoicePlayer] got result:', result)
|
||||
if (result.success && result.data) {
|
||||
console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length)
|
||||
// 使用 Blob URL 替代 data URL,绕过 CSP 限制
|
||||
const byteCharacters = atob(result.data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: 'audio/wav' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
console.log('[VoicePlayer] created blob URL:', blobUrl)
|
||||
setAudioUrl(blobUrl)
|
||||
} else {
|
||||
console.log('[VoicePlayer] getVoiceData failed:', result.error)
|
||||
setError(result.error || '获取语音数据失败')
|
||||
setShouldAutoPlay(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[VoicePlayer] exception:', e)
|
||||
setError('加载语音失败')
|
||||
setShouldAutoPlay(false)
|
||||
}
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioRef.current) {
|
||||
console.log('[VoicePlayer] no audioRef')
|
||||
return
|
||||
}
|
||||
|
||||
const audio = audioRef.current
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
globalStopCallback = null
|
||||
} else {
|
||||
const playAudio = useCallback(
|
||||
async (audio: HTMLAudioElement): Promise<void> => {
|
||||
stopCurrentAndPlay(audio)
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
console.log('[VoicePlayer] play() succeeded')
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log('[VoicePlayer] play() failed:', e)
|
||||
})
|
||||
setIsPlaying(true)
|
||||
globalStopCallback = () => {
|
||||
setIsPlaying(false)
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
}, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) return
|
||||
|
||||
let audio = audioRef.current
|
||||
if (!audio) {
|
||||
audio = new Audio(audioUrl)
|
||||
audioRef.current = audio
|
||||
}
|
||||
|
||||
const audioEl = audio!
|
||||
|
||||
audioEl.addEventListener('loadedmetadata', () => {
|
||||
setAudioDuration(audioEl.duration)
|
||||
console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration)
|
||||
})
|
||||
|
||||
audioEl.addEventListener('ended', () => {
|
||||
setIsPlaying(false)
|
||||
globalStopCallback = null
|
||||
})
|
||||
|
||||
audioEl.addEventListener('timeupdate', () => {
|
||||
if (audioEl.duration && isFinite(audioEl.duration)) {
|
||||
setAudioDuration(audioEl.duration)
|
||||
}
|
||||
})
|
||||
|
||||
audioEl.addEventListener('canplay', () => {
|
||||
console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay)
|
||||
if (shouldAutoPlay && audioRef.current) {
|
||||
setShouldAutoPlay(false)
|
||||
stopCurrentAndPlay(audioRef.current)
|
||||
audioRef.current.play()
|
||||
try {
|
||||
await audio.play()
|
||||
setError(null)
|
||||
setIsPlaying(true)
|
||||
globalStopCallback = () => {
|
||||
setIsPlaying(false)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
audio.currentTime = 0
|
||||
}
|
||||
} catch (playError) {
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
setIsPlaying(false)
|
||||
setError('语音播放失败,请重试')
|
||||
console.warn('[VoicePlayer] play failed:', playError)
|
||||
}
|
||||
})
|
||||
},
|
||||
[stopCurrentAndPlay]
|
||||
)
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ''
|
||||
audioRef.current = null
|
||||
}
|
||||
if (globalCurrentAudio === audioRef.current) {
|
||||
const createAudio = useCallback((blobUrl: string): HTMLAudioElement => {
|
||||
const audio = new Audio()
|
||||
audio.preload = 'auto'
|
||||
audio.src = blobUrl
|
||||
audio.onloadedmetadata = () => {
|
||||
if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
|
||||
}
|
||||
audio.ontimeupdate = () => {
|
||||
if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
|
||||
}
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false)
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
}
|
||||
}, [audioUrl, shouldAutoPlay, stopCurrentAndPlay])
|
||||
audioRef.current = audio
|
||||
objectUrlRef.current = blobUrl
|
||||
return audio
|
||||
}, [])
|
||||
|
||||
const handlePlayPause = useCallback(async () => {
|
||||
if (loading) return
|
||||
|
||||
let audio = audioRef.current
|
||||
if (!audio) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
|
||||
if (result.success && result.data) {
|
||||
const byteCharacters = atob(result.data)
|
||||
const byteArray = new Uint8Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteArray[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: 'audio/wav' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
setAudioUrl(blobUrl)
|
||||
audio = createAudio(blobUrl)
|
||||
await playAudio(audio)
|
||||
} else {
|
||||
setError(result.error || '获取语音数据失败')
|
||||
}
|
||||
} catch (loadError) {
|
||||
console.warn('[VoicePlayer] load failed:', loadError)
|
||||
setError('加载语音失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
} else {
|
||||
await playAudio(audio)
|
||||
}
|
||||
}, [createAudio, createTime, isPlaying, loading, localId, playAudio, sessionId, svrId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const audio = audioRef.current
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
}
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)
|
||||
objectUrlRef.current = null
|
||||
audioRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const formatDuration = (seconds: number | undefined): string => {
|
||||
if (!seconds || !isFinite(seconds)) return '0:00'
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SelfInfo {
|
||||
interface AccountSummaryProps {
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
compact?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
@@ -17,13 +18,20 @@ interface AccountSummaryProps {
|
||||
export function AccountSummary({
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
compact = false,
|
||||
onClick
|
||||
}: AccountSummaryProps): React.ReactElement {
|
||||
const showAccount = Boolean(selfInfo && (dbReady || dbConnecting))
|
||||
const displayName =
|
||||
dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
|
||||
const subtitle = dbReady && selfInfo ? selfInfo.wxid : '打开设置'
|
||||
const statusText = dbReady ? '数据库已连接' : '数据库未连接'
|
||||
showAccount && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
|
||||
const subtitle = showAccount && selfInfo ? selfInfo.wxid : '打开设置'
|
||||
const statusText = dbReady
|
||||
? '数据库已连接'
|
||||
: dbConnecting
|
||||
? '正在连接数据库'
|
||||
: '数据库未连接'
|
||||
const statusClass = dbReady ? 'ready' : dbConnecting ? 'connecting' : ''
|
||||
const initial = (displayName || '?').charAt(0)
|
||||
const title = `${displayName}\n${subtitle}`
|
||||
const avatar = (
|
||||
@@ -33,7 +41,7 @@ export function AccountSummary({
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
<span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden />
|
||||
<span className={`account-summary-status ${statusClass}`} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -52,7 +60,7 @@ export function AccountSummary({
|
||||
<span className="account-summary-name">{displayName}</span>
|
||||
<span className="account-summary-meta">{subtitle}</span>
|
||||
<span className="account-summary-state">
|
||||
<span className={`account-summary-state-dot ${dbReady ? 'ready' : ''}`} aria-hidden />
|
||||
<span className={`account-summary-state-dot ${statusClass}`} aria-hidden />
|
||||
{statusText}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -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,27 @@ 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,25 +42,32 @@ export function MessageBubble({
|
||||
isGroupChat,
|
||||
isMine,
|
||||
showAvatarSpace,
|
||||
onImageClick
|
||||
onImageClick,
|
||||
isJumpTarget
|
||||
}: MessageBubbleProps): React.ReactElement {
|
||||
const isVoice = message.type === '语音'
|
||||
const isImage = message.type === '图片'
|
||||
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
||||
const isVideo = message.type === '视频'
|
||||
const isRichMedia =
|
||||
RICH_MESSAGE_TYPES.includes(message.type) || message.contentData?.type === 'unknown'
|
||||
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
|
||||
sessionId={message.sessionId}
|
||||
localId={message.localId || 0}
|
||||
createTime={message.createTime || 0}
|
||||
svrId={message.serverId}
|
||||
duration={message.voiceDuration}
|
||||
/>
|
||||
) : isImage && message.contentData && message.contentData.type === 'image' ? (
|
||||
<ImageBubble
|
||||
@@ -50,12 +76,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>
|
||||
|
||||
@@ -9,12 +9,15 @@ interface MessageListProps {
|
||||
messages: Message[]
|
||||
hiddenMessageCount: number
|
||||
isLoadingMessages?: boolean
|
||||
messageHistoryStatus?: 'idle' | 'end' | 'error'
|
||||
isGroupChat: boolean
|
||||
showAvatar: boolean
|
||||
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({
|
||||
@@ -22,16 +25,20 @@ export function MessageList({
|
||||
messages,
|
||||
hiddenMessageCount,
|
||||
isLoadingMessages,
|
||||
messageHistoryStatus,
|
||||
isGroupChat,
|
||||
showAvatar,
|
||||
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,10 +47,92 @@ 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>}
|
||||
{messageHistoryStatus === 'end' && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">已显示本地数据库中的最早记录</div>
|
||||
</div>
|
||||
)}
|
||||
{messageHistoryStatus === 'error' && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">
|
||||
无法读取更早记录,请检查数据目录或当前微信数据版本
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hiddenMessageCount > 0 && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">
|
||||
@@ -60,7 +149,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 +158,7 @@ export function MessageList({
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
onImageClick={onImageClick}
|
||||
jumpTargetMessageId={jumpTarget?.messageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
|
||||
interface ConversationItemProps {
|
||||
@@ -16,6 +16,26 @@ export function ConversationItem({
|
||||
const wxid = contact.m_nsUsrName
|
||||
const displayName = nickname || wxid || '未命名会话'
|
||||
const initial = (displayName || wxid || '?').charAt(0)
|
||||
const [repairedAvatar, setRepairedAvatar] = useState<{ username: string; source: string }>()
|
||||
const [failedAvatar, setFailedAvatar] = useState<{ username: string; source: string }>()
|
||||
const repairedSource = repairedAvatar?.username === wxid ? repairedAvatar.source : undefined
|
||||
const avatar = repairedSource || contact.avatar
|
||||
const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar
|
||||
|
||||
const handleAvatarError = (): void => {
|
||||
if (!avatar || avatarFailed) return
|
||||
setFailedAvatar({ username: wxid, source: avatar })
|
||||
if (contact.type !== 'group' || avatar.startsWith('data:')) return
|
||||
void window.api
|
||||
.getContactAvatars([wxid])
|
||||
.then((avatars) => {
|
||||
const fallback = avatars[wxid]
|
||||
if (!fallback || fallback === avatar) return
|
||||
setRepairedAvatar({ username: wxid, source: fallback })
|
||||
setFailedAvatar(undefined)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -26,13 +46,14 @@ export function ConversationItem({
|
||||
>
|
||||
<span className="conversation-item-active-mark" aria-hidden />
|
||||
<span className="conversation-item-avatar">
|
||||
{contact.avatar ? (
|
||||
{avatar && !avatarFailed ? (
|
||||
<img
|
||||
src={contact.avatar}
|
||||
src={avatar}
|
||||
alt={displayName}
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={handleAvatarError}
|
||||
/>
|
||||
) : (
|
||||
initial
|
||||
|
||||
@@ -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,100 @@ export interface ConversationSidebarProps {
|
||||
onSearch: (keyword: string) => void
|
||||
onContentFilter: (keyword: string) => void
|
||||
width: number
|
||||
dateRange: string
|
||||
onDateRangeChange: (range: string) => void
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
type SectionName = 'groups' | 'folded' | '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,
|
||||
dbConnecting = false,
|
||||
onOpenSettings
|
||||
}: ConversationSidebarProps): React.ReactElement {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [expandedSection, setExpandedSection] = useState<'groups' | 'contacts'>('groups')
|
||||
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||
groups: true,
|
||||
folded: false,
|
||||
contacts: false
|
||||
})
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const groups = contacts.filter((contact) => contact.type === 'group')
|
||||
const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded)
|
||||
const foldedGroups = contacts.filter((contact) => contact.type === 'group' && contact.isFolded)
|
||||
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
|
||||
}))
|
||||
: []),
|
||||
...(foldedGroups.length
|
||||
? [
|
||||
{
|
||||
kind: 'header' as const,
|
||||
id: 'folded-header',
|
||||
title: '折叠群聊',
|
||||
count: foldedGroups.length,
|
||||
section: 'folded' as const
|
||||
},
|
||||
...(expandedSections.folded
|
||||
? foldedGroups.map((contact) => ({
|
||||
kind: 'contact' as const,
|
||||
id: `folded-${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.folded,
|
||||
expandedSections.groups,
|
||||
foldedGroups,
|
||||
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,32 +124,71 @@ 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} />
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -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,597 @@
|
||||
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(true)
|
||||
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
|
||||
// Runs only from the export button event; a fresh id is required for each job.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
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,
|
||||
preferOriginal,
|
||||
fallbackThumbnail,
|
||||
keepMissing,
|
||||
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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKinds.has(value)}
|
||||
onChange={() => toggleKind(value)}
|
||||
/>
|
||||
<span>{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 导出中生效,CSV、JSON 和 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
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
export type LoadedImage = {
|
||||
data: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
export type ImageLoadOptions = {
|
||||
force?: boolean
|
||||
preferThumbnail?: boolean
|
||||
}
|
||||
|
||||
type QueueItem = {
|
||||
requestKey: string
|
||||
priority: number
|
||||
run: () => Promise<LoadedImage>
|
||||
resolve: (value: LoadedImage) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
// Cache probes are cheap asynchronous IPC calls. Cold decrypts are serialized
|
||||
// in the main process, so renderer concurrency only controls how quickly disk
|
||||
// cache hits can become visible after a restart.
|
||||
const MAX_CONCURRENT_IMAGE_LOADS = 32
|
||||
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
|
||||
if (options.force && cached.isThumbnail) {
|
||||
imageCache.delete(key)
|
||||
imageCacheBytes -= imageCacheSizes.get(key) || 0
|
||||
imageCacheSizes.delete(key)
|
||||
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 {
|
||||
// A forced original request may temporarily fall back to a thumbnail. Do not
|
||||
// let that fallback prevent a later retry after WeChat downloads the original.
|
||||
if (options.force && image.isThumbnail) return
|
||||
|
||||
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 {
|
||||
if (activeImageLoads >= MAX_CONCURRENT_IMAGE_LOADS || imageQueue.length === 0) return
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isSupportedImageUrl(value: string | undefined): value is string {
|
||||
return Boolean(value?.startsWith('data:image/') || value?.startsWith('wxe-media://'))
|
||||
}
|
||||
|
||||
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) {
|
||||
const queuedItem = imageQueue.find((item) => item.requestKey === requestKey)
|
||||
if (queuedItem && priority < queuedItem.priority) queuedItem.priority = priority
|
||||
return existingRequest
|
||||
}
|
||||
|
||||
const request = new Promise<LoadedImage>((resolve, reject) => {
|
||||
imageQueue.push({
|
||||
requestKey,
|
||||
priority,
|
||||
resolve,
|
||||
reject,
|
||||
run: async () => {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, {
|
||||
...options,
|
||||
priority
|
||||
})
|
||||
if (!result.success || !isSupportedImageUrl(result.data)) {
|
||||
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
|
||||
}
|
||||
@@ -15,8 +15,12 @@ interface AppShellProps {
|
||||
activePage: AppPage
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onPageChange: (page: AppPage) => void
|
||||
onOpenSettings: () => void
|
||||
onOpenGuide: () => void
|
||||
appearanceTheme?: 'system' | 'light' | 'dark'
|
||||
compactMode?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -32,19 +36,42 @@ export function AppShell({
|
||||
activePage,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onPageChange,
|
||||
onOpenSettings,
|
||||
onOpenGuide,
|
||||
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} />
|
||||
<button
|
||||
type="button"
|
||||
className="app-guide-launcher"
|
||||
onClick={onOpenGuide}
|
||||
title="重新打开新手引导"
|
||||
>
|
||||
<span className="app-guide-launcher-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M12 3 14.2 9.8 21 12l-6.8 2.2L12 21l-2.2-6.8L3 12l6.8-2.2L12 3Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="app-guide-launcher-label">新手引导</span>
|
||||
</button>
|
||||
<div className="app-rail-account">
|
||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} compact onClick={onOpenSettings} />
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
compact
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="app-shell-main" aria-label={activeItem?.label || '工作区'}>
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface NavigationItem {
|
||||
|
||||
export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: 'archive', label: '档案' },
|
||||
{ id: 'search', label: '检索' },
|
||||
{ id: 'search', label: '问问微信' },
|
||||
{ id: 'report', label: '日报' },
|
||||
{ id: 'agent-hub', label: 'Agent' },
|
||||
{ id: 'export', label: '导出' },
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ReportHistorySidebarProps {
|
||||
selectedReportId: string | null
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onSelectReport: (reportId: string) => void
|
||||
onCreateReport: () => void
|
||||
onDeleteReport: (reportId: string) => Promise<{ success: boolean; error?: string }>
|
||||
@@ -80,6 +81,7 @@ export function ReportHistorySidebar({
|
||||
selectedReportId,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onSelectReport,
|
||||
onCreateReport,
|
||||
onDeleteReport,
|
||||
@@ -202,7 +204,12 @@ export function ReportHistorySidebar({
|
||||
)}
|
||||
</div>
|
||||
<div className="report-history-account">
|
||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
{pendingDelete && (
|
||||
<div className="report-delete-confirm" role="dialog" aria-modal="true">
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ReportSourceSidebarProps {
|
||||
selectedContact: Contact | null
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onSelectContact: (contact: Contact) => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export function ReportSourceSidebar({
|
||||
selectedContact,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onSelectContact,
|
||||
onOpenSettings
|
||||
}: ReportSourceSidebarProps): React.ReactElement {
|
||||
@@ -95,6 +97,7 @@ export function ReportSourceSidebar({
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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>问问你的微信</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'
|
||||
|
||||
@@ -14,6 +19,7 @@ export function SettingsWorkspace({
|
||||
onCategoryChange,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
dbKey,
|
||||
onDbKeyChange,
|
||||
onDatabaseConnectionChange,
|
||||
@@ -23,12 +29,15 @@ export function SettingsWorkspace({
|
||||
onReturnToLogin,
|
||||
onAIRuntimeChange,
|
||||
onNotice,
|
||||
onOpenSettings
|
||||
onOpenSettings,
|
||||
onAppearanceChange,
|
||||
onSwitchAccount
|
||||
}: {
|
||||
selectedCategory: SettingsCategoryId
|
||||
onCategoryChange: (id: SettingsCategoryId) => void
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
dbKey: string
|
||||
onDbKeyChange: (key: string) => void
|
||||
onDatabaseConnectionChange: (connected: boolean) => void
|
||||
@@ -39,7 +48,61 @@ export function SettingsWorkspace({
|
||||
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
||||
onNotice: (message: string) => void
|
||||
onOpenSettings: () => void
|
||||
onAppearanceChange: (settings: {
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
}) => void
|
||||
onSwitchAccount: (
|
||||
account: import('../../../../shared/database-key').WechatAccountCandidate
|
||||
) => Promise<void>
|
||||
}): React.ReactElement {
|
||||
const renderSelectedPage = (): React.ReactElement => {
|
||||
switch (selectedCategory) {
|
||||
case 'account-database':
|
||||
return (
|
||||
<AccountDatabasePage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
selfInfo={selfInfo}
|
||||
onNotice={onNotice}
|
||||
onSwitchAccount={onSwitchAccount}
|
||||
/>
|
||||
)
|
||||
case 'database-key':
|
||||
return (
|
||||
<DatabaseKeyPage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
selfInfo={selfInfo}
|
||||
onDbKeyChange={onDbKeyChange}
|
||||
onDatabaseConnectionChange={onDatabaseConnectionChange}
|
||||
onSelfInfoChange={onSelfInfoChange}
|
||||
onContactsChange={onContactsChange}
|
||||
onFilteredContactsChange={onFilteredContactsChange}
|
||||
onReturnToLogin={onReturnToLogin}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
)
|
||||
case 'image-key':
|
||||
return <ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||
case 'ai-model':
|
||||
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||
case 'recall-protection':
|
||||
return <RecallProtectionPage onNotice={onNotice} />
|
||||
case 'advanced':
|
||||
return <AdvancedPage onNotice={onNotice} />
|
||||
case 'cache-cleanup':
|
||||
return <CacheCleanupPage onNotice={onNotice} />
|
||||
case 'appearance':
|
||||
return <AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
|
||||
case 'about':
|
||||
return <AboutPage onNotice={onNotice} />
|
||||
default:
|
||||
return <SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-workspace">
|
||||
<SettingsSidebar
|
||||
@@ -47,35 +110,10 @@ export function SettingsWorkspace({
|
||||
onSelect={onCategoryChange}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
{selectedCategory === 'account-database' ? (
|
||||
<AccountDatabasePage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
selfInfo={selfInfo}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
) : selectedCategory === 'database-key' ? (
|
||||
<DatabaseKeyPage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
selfInfo={selfInfo}
|
||||
onDbKeyChange={onDbKeyChange}
|
||||
onDatabaseConnectionChange={onDatabaseConnectionChange}
|
||||
onSelfInfoChange={onSelfInfoChange}
|
||||
onContactsChange={onContactsChange}
|
||||
onFilteredContactsChange={onFilteredContactsChange}
|
||||
onReturnToLogin={onReturnToLogin}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
) : selectedCategory === 'image-key' ? (
|
||||
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||
) : selectedCategory === 'ai-model' ? (
|
||||
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||
) : (
|
||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||
)}
|
||||
<div className="settings-page-panel active">{renderSelectedPage()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export function AccountOverview({
|
||||
isChecking,
|
||||
onCheck,
|
||||
onOpenDirectory,
|
||||
onCopyDirectory
|
||||
onCopyDirectory,
|
||||
onSwitchAccount
|
||||
}: {
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
connectionStatus: ConnectionOverviewStatus
|
||||
@@ -34,6 +35,7 @@ export function AccountOverview({
|
||||
onCheck: () => void
|
||||
onOpenDirectory: () => void
|
||||
onCopyDirectory: () => void
|
||||
onSwitchAccount: () => void
|
||||
}): React.ReactElement {
|
||||
const accountRoot = selfInfo?.accountRoot || ''
|
||||
return (
|
||||
@@ -83,6 +85,9 @@ export function AccountOverview({
|
||||
>
|
||||
打开账号目录
|
||||
</button>
|
||||
<button type="button" className="api-secondary-button" onClick={onSwitchAccount}>
|
||||
切换账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-account-root">
|
||||
|
||||
@@ -13,11 +13,13 @@ import type { ConnectionCheckState } from './types'
|
||||
export function useAccountDatabaseController({
|
||||
dbKey,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
selfInfo,
|
||||
onNotice
|
||||
}: {
|
||||
dbKey: string
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
onNotice: (message: string) => void
|
||||
}) {
|
||||
@@ -28,7 +30,7 @@ export function useAccountDatabaseController({
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api
|
||||
.getImageDecryptionStatus()
|
||||
.getImageKeyConfig()
|
||||
.then((result) => {
|
||||
if (active) setHasImageKey(result.configured)
|
||||
})
|
||||
@@ -58,8 +60,11 @@ export function useAccountDatabaseController({
|
||||
[checkState, dbKey, dbReady, hasImageKey, selfInfo]
|
||||
)
|
||||
const connectionStatus = useMemo(
|
||||
() => getConnectionOverviewStatus({ dbReady, checkState, diagnostics }),
|
||||
[checkState, dbReady, diagnostics]
|
||||
() =>
|
||||
dbConnecting
|
||||
? ('checking' as const)
|
||||
: getConnectionOverviewStatus({ dbReady, checkState, diagnostics }),
|
||||
[checkState, dbConnecting, dbReady, diagnostics]
|
||||
)
|
||||
const lastCheckedLabel = formatConnectionCheckedAt(checkState, clock)
|
||||
|
||||
@@ -121,7 +126,7 @@ export function useAccountDatabaseController({
|
||||
diagnostics,
|
||||
connectionStatus,
|
||||
checkState,
|
||||
isChecking: checkState.status === 'checking',
|
||||
isChecking: dbConnecting || checkState.status === 'checking',
|
||||
lastCheckedLabel,
|
||||
testConnection,
|
||||
openAccountDirectory,
|
||||
|
||||
@@ -8,12 +8,14 @@ export function SettingsSidebar({
|
||||
onSelect,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onOpenSettings
|
||||
}: {
|
||||
selectedId: SettingsCategoryId
|
||||
onSelect: (id: SettingsCategoryId) => void
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onOpenSettings: () => void
|
||||
}): React.ReactElement {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
@@ -57,7 +59,12 @@ export function SettingsSidebar({
|
||||
))}
|
||||
</div>
|
||||
<div className="settings-sidebar-account">
|
||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -36,14 +36,14 @@ export function useDatabaseKeyController({
|
||||
}, [])
|
||||
|
||||
const refreshStorage = useCallback(async (): Promise<void> => {
|
||||
const result = await window.api.getSavedDbKey()
|
||||
const result = await window.api.getSavedDbKey(selfInfo?.accountRoot || '')
|
||||
dispatch({
|
||||
type: 'STORAGE_LOADED',
|
||||
saved: result.saved,
|
||||
encryptionAvailable: result.encryptionAvailable,
|
||||
error: result.success ? undefined : result.error
|
||||
})
|
||||
}, [])
|
||||
}, [selfInfo?.accountRoot])
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([refreshStorage(), refreshEnvironment()])
|
||||
@@ -109,7 +109,8 @@ export function useDatabaseKeyController({
|
||||
const saveKey = useCallback(async (): Promise<void> => {
|
||||
if (state.status !== 'valid') return
|
||||
dispatch({ type: 'SAVE_START' })
|
||||
const saved = await window.api.saveDbKey(dbKey)
|
||||
const accountRoot = selfInfo?.accountRoot || ''
|
||||
const saved = await window.api.saveDbKey(accountRoot, dbKey)
|
||||
if (!saved.success || !saved.key) {
|
||||
dispatch({
|
||||
type: 'SAVE_ERROR',
|
||||
@@ -117,12 +118,12 @@ export function useDatabaseKeyController({
|
||||
})
|
||||
return
|
||||
}
|
||||
const stored = await window.api.getSavedDbKey()
|
||||
const stored = await window.api.getSavedDbKey(accountRoot)
|
||||
if (!stored.success || !stored.saved || !stored.key) {
|
||||
dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' })
|
||||
return
|
||||
}
|
||||
const initialized = await window.api.initDb(stored.key)
|
||||
const initialized = await window.api.initDb(stored.key, accountRoot)
|
||||
const connected = typeof initialized === 'boolean' ? initialized : initialized.success
|
||||
onDbKeyChange(stored.key)
|
||||
onDatabaseConnectionChange(connected)
|
||||
@@ -148,13 +149,14 @@ export function useDatabaseKeyController({
|
||||
onNotice,
|
||||
onSelfInfoChange,
|
||||
refreshEnvironment,
|
||||
selfInfo?.accountRoot,
|
||||
state.status
|
||||
])
|
||||
|
||||
const autoDetectKey = useCallback(async (): Promise<void> => {
|
||||
dispatch({ type: 'AUTO_START' })
|
||||
await refreshEnvironment()
|
||||
const result = await window.api.autoGetDbKey({ save: false })
|
||||
const result = await window.api.autoGetDbKey(selfInfo?.accountRoot || '', { save: false })
|
||||
if (!result.success || !result.key) {
|
||||
dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' })
|
||||
return
|
||||
@@ -162,11 +164,11 @@ export function useDatabaseKeyController({
|
||||
onDbKeyChange(result.key)
|
||||
dispatch({ type: 'AUTO_SUCCESS' })
|
||||
await runValidation(result.key)
|
||||
}, [onDbKeyChange, refreshEnvironment, runValidation])
|
||||
}, [onDbKeyChange, refreshEnvironment, runValidation, selfInfo?.accountRoot])
|
||||
|
||||
const clearSavedKey = useCallback(async (): Promise<void> => {
|
||||
dispatch({ type: 'CLEAR_START' })
|
||||
const result = await window.api.clearSavedDbKey()
|
||||
const result = await window.api.clearSavedDbKey(selfInfo?.accountRoot || '')
|
||||
if (!result.success) {
|
||||
dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' })
|
||||
return
|
||||
@@ -187,7 +189,8 @@ export function useDatabaseKeyController({
|
||||
onFilteredContactsChange,
|
||||
onNotice,
|
||||
onSelfInfoChange,
|
||||
refreshEnvironment
|
||||
refreshEnvironment,
|
||||
selfInfo?.accountRoot
|
||||
])
|
||||
|
||||
const returnToLogin = useCallback(async (): Promise<void> => {
|
||||
|
||||
@@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({
|
||||
{state.status.platform === 'darwin'
|
||||
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
|
||||
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
|
||||
<br />
|
||||
<small className="image-auto-scope-hint">
|
||||
仅支持 WeChat 4.0,V3 及以下无法解析
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ImageDecoderStatus } from '../../../../../shared/image-decryption'
|
||||
|
||||
interface ImageDecoderRequirementNoticeProps {
|
||||
status?: ImageDecoderStatus
|
||||
onNotice: (message: string) => void
|
||||
}
|
||||
|
||||
export function ImageDecoderRequirementNotice({
|
||||
status,
|
||||
onNotice
|
||||
}: ImageDecoderRequirementNoticeProps): React.ReactElement {
|
||||
const platform = window.electron.process.platform
|
||||
const [currentStatus, setCurrentStatus] = useState(status)
|
||||
const [selecting, setSelecting] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
useEffect(() => setCurrentStatus(status), [status])
|
||||
|
||||
const selectDirectory = async (): Promise<void> => {
|
||||
setSelecting(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await window.api.selectImageDecoder()
|
||||
if (result.canceled) return
|
||||
if (!result.success || !result.status) {
|
||||
setError(result.error || '没有在所选文件夹中找到 FFmpeg,请重新选择。')
|
||||
return
|
||||
}
|
||||
setCurrentStatus(result.status)
|
||||
onNotice(
|
||||
result.status.available
|
||||
? 'FFmpeg 安装目录已保存,原图支持可以使用'
|
||||
: 'FFmpeg 安装目录已保存,但原图支持未通过检测'
|
||||
)
|
||||
} catch {
|
||||
setError('无法打开目录选择窗口,请稍后重试。')
|
||||
} finally {
|
||||
setSelecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkOriginalSupport = async (): Promise<void> => {
|
||||
setChecking(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const nextStatus = await window.api.getImageDecoderStatus()
|
||||
setCurrentStatus(nextStatus)
|
||||
onNotice(nextStatus.available ? '原图支持检测通过' : '原图支持检测未通过')
|
||||
} catch {
|
||||
setError('原图支持检测失败,请稍后重试。')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDownload = async (): Promise<void> => {
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await window.api.openImageDecoderDownload()
|
||||
if (!result.success) setError(result.error || '无法打开 FFmpeg 下载页面')
|
||||
} catch {
|
||||
setError('无法打开 FFmpeg 下载页面,请检查系统默认浏览器设置。')
|
||||
}
|
||||
}
|
||||
|
||||
const installed = currentStatus?.installed === true
|
||||
const supported = currentStatus?.available === true
|
||||
const downloadLabel = platform === 'darwin' ? '打开 Homebrew 官网' : '下载 FFmpeg'
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`image-decoder-requirement ${supported ? 'ready' : ''}`}
|
||||
aria-label="FFmpeg 原图支持"
|
||||
>
|
||||
<span className="image-decoder-requirement-icon" aria-hidden>
|
||||
{supported ? '✓' : '!'}
|
||||
</span>
|
||||
<div className="image-decoder-requirement-body">
|
||||
<div className="image-decoder-requirement-heading">
|
||||
<strong>FFmpeg 原图支持</strong>
|
||||
<span>
|
||||
{supported ? '原图支持可用' : installed ? 'FFmpeg 已安装' : '需要安装 FFmpeg'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>如需打开少量 wxgf/HEVC 特殊原图,必须安装 FFmpeg。未安装不会影响聊天记录和普通图片。</p>
|
||||
|
||||
<div className="image-decoder-checks">
|
||||
<div>
|
||||
<span className={`image-decoder-check-index ${installed ? 'complete' : ''}`}>1</span>
|
||||
<div>
|
||||
<strong>FFmpeg 安装目录</strong>
|
||||
<small title={currentStatus?.directory}>
|
||||
{currentStatus?.directory || '尚未检测到 FFmpeg 安装目录'}
|
||||
</small>
|
||||
</div>
|
||||
<b className={installed ? 'success' : ''}>{installed ? '已检测' : '未检测'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`image-decoder-check-index ${supported ? 'complete' : ''}`}>2</span>
|
||||
<div>
|
||||
<strong>原图支持检测</strong>
|
||||
<small>
|
||||
{supported
|
||||
? '已支持 wxgf/HEVC 特殊原图转换'
|
||||
: installed
|
||||
? '尚未检测到 HEVC 原图转换能力'
|
||||
: '请先安装并检测 FFmpeg 目录'}
|
||||
</small>
|
||||
</div>
|
||||
<b className={supported ? 'success' : ''}>{supported ? '已通过' : '未通过'}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="image-decoder-requirement-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-header-action"
|
||||
onClick={() => void openDownload()}
|
||||
>
|
||||
{downloadLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-primary-button"
|
||||
disabled={selecting}
|
||||
onClick={() => void selectDirectory()}
|
||||
>
|
||||
{selecting ? '正在保存…' : '填写 FFmpeg 安装目录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-header-action"
|
||||
disabled={!installed || checking}
|
||||
onClick={() => void checkOriginalSupport()}
|
||||
>
|
||||
{checking ? '正在检测…' : '检测原图支持'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="image-decoder-platform-help">
|
||||
{platform === 'win32' ? (
|
||||
<>
|
||||
<strong>Windows 安装与定位</strong>
|
||||
<p>
|
||||
下载后右键压缩包选择“全部解压”,再填写解压后的文件夹。如果已经安装但没有自动找到,可在
|
||||
PowerShell 输入 <code>(Get-Command ffmpeg).Source</code>,或在命令提示符(CMD)输入{' '}
|
||||
<code>where ffmpeg</code>,然后选择返回路径所在的 bin 文件夹。
|
||||
</p>
|
||||
</>
|
||||
) : platform === 'darwin' ? (
|
||||
<>
|
||||
<strong>macOS 安装与定位</strong>
|
||||
<p>
|
||||
打开“终端”并输入 <code>brew install ffmpeg</code>。如果提示没有
|
||||
brew,请先通过上方按钮打开 Homebrew 官网完成安装。安装后输入{' '}
|
||||
<code>which ffmpeg</code>,再选择返回路径所在的文件夹, 通常是{' '}
|
||||
<code>/opt/homebrew/bin</code> 或 <code>/usr/local/bin</code>。
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Linux 安装与定位</strong>
|
||||
<p>
|
||||
通过系统包管理器安装 FFmpeg,执行 <code>which ffmpeg</code>{' '}
|
||||
查看位置,再选择返回路径所在的文件夹。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="image-decoder-requirement-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||