Compare commits

..
Author SHA1 Message Date
Wxw-Gu d76727875d chore: 提升版本 2026-08-03 14:12:00 +08:00
Wxw-Gu 08e1294e5d feat: 完善多账号连接诊断与聊天媒体导出
- 新增微信账号发现、环境诊断和分步数据库连接引导
- 支持按账号安全保存数据库密钥及快速切换账号
- 完善 WCDB 历史消息分片读取和分页状态提示
- 支持导出图片、视频和语音,提供原图优先及缩略图回退
- 更新安装指引、兼容版本说明和相关自动化测试
2026-08-03 10:43:14 +08:00
电摇小子andWxw-Gu 224308f0e0 fix: 修复 macOS 会话昵称显示
(cherry picked from commit be092c64e528cfc363f0ddd32c480eea5532799f)
2026-08-03 10:08:59 +08:00
电摇小子andWxw-Gu 3e57a8432d test: 建立桌面端自动化回归测试体系并完善跨平台 CI
(cherry picked from commit 74267ae2f63f8256c3da84b04f5b330e6e9d4c67)
2026-08-03 10:08:28 +08:00
电摇小子andWxw-Gu b4f909a597 fix: 修复语音首播并完善消息解析与会话兼容性
(cherry picked from commit 214090192f5dfc91cdec0269bad434d3e22394d8)
2026-08-03 10:04:45 +08:00
电摇小子andWxw-Gu ee7dc11e92 fix: 优化数据库启动与设置页响应性能
(cherry picked from commit f6614548b0e742bc703b2a5aaa5060720985ed27)
2026-08-03 10:04:19 +08:00
电摇小子andWxw-Gu 90bf1aed90 fix: 支持 wxgf 原图解密并修复缩略图缓存刷新
- 增加 wxgf/HEVC 图片转换支持
- 增加 FFmpeg 跨平台安装、目录填写与能力检测
- 避免缩略图占用原图缓存,下载原图后可即时刷新
- 移除聊天图片的缩略图角标

(cherry picked from commit 4cee159a65496bd30dd690b568c47a120f3fff30)
2026-08-03 10:03:55 +08:00
电摇小子andWxw-Gu a3955d691d fix: 优化 Windows 启动性能与聊天图片缓存
- 增加聊天图片磁盘持久化缓存,重启后直接复用
- 将 DAT 图片解密移至 Worker,避免阻塞主进程
- 增加图片加载优先级和并发控制
- 优化会话目录与图片文件的异步查找
- 使用本地媒体协议加载缓存图片,减少 Base64 IPC 开销
- 优化启动缓存与数据库初始化流程,降低窗口未响应时间

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

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

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

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

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

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

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

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

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

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

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

修复源码乱码注释并统一 UTF-8 编码。
2026-07-24 14:16:34 +08:00
Wxw-Gu 430a36333b docs: 修改Readme 2026-07-24 11:28:10 +08:00
Wxw-Gu 474250c6c7 feat: 添加微信消息防撤回功能 2026-07-24 11:27:44 +08:00
Wxw-Gu 794880d389 chore: 提升版本 2026-07-23 16:25:08 +08:00
Wxw-Gu d0ceeceb68 fix: 容错修复群聊日报 JSON, 图片样式问题 2026-07-23 16:20:45 +08:00
Wxw-Gu 62f729d281 chore: 添加应用诊断日志 2026-07-23 16:09:20 +08:00
Wxw-Gu 0d544275c0 chore: 删除图片 2026-07-17 14:46:11 +08:00
Wxw-Gu a91b4d7a56 docs: 添加交流二维码并升级版本 2026-07-17 14:44:50 +08:00
Wxw-Gu f9b567fba2 feat: 重构数据库登录与路径发现 2026-07-17 14:35:53 +08:00
Wxw-Gu 1f693aa3d7 perf: 优化聊天渲染与后台任务性能 2026-07-17 11:37:02 +08:00
Wxw-Gu 8e1e166856 merge: 合并日报名称与图片识别修复 2026-07-15 21:18:02 +08:00
Wxw-Gu 1275c512c4 fix: 修复日报成员名称与图片识别兼容性 2026-07-15 21:17:54 +08:00
Wxw-Gu 1abaa57a0c merge: 合并 AI 微信 Agent 能力 2026-07-15 20:50:04 +08:00
Wxw-Gu d08db46479 feat: 完善跨平台发布与生产密钥隔离 2026-07-15 20:49:47 +08:00
Wxw-Gu eaea8d8435 feat: 内置微信连接器并完善 Agent 查询能力 2026-07-15 20:27:05 +08:00
电摇小子andWxw-Gu 1e97953d67 feat: 集成 Agent Hub 微信机器人能力 2026-07-15 17:26:15 +08:00
Wxw-Gu 597667e005 merge: 合并群聊日报与图片理解功能 2026-07-15 16:32:35 +08:00
Wxw-Gu 515348b6d8 feat: 完善群聊日报模板与图片理解 2026-07-15 16:29:25 +08:00
Wxw-Gu e150605c91 merge: feat/report 集成进 feat/newReport (M1)
合并 feat/report 的日报功能 + main 的图片识别基建:
- 保留 main 分支的图片识别能力(ai-provider-service + key-store + settings/ai-model)
- 保留 feat/report 的日报功能(ChatWindow.tsx + group-report* + mobile_daily_report.html)
- index.ts/preload 同时保留两边的 IPC handler(api:chat / ai:testVision / report:export / db:getImage)

冲突解决:
- main/index.ts ai:chat:采用 main 版本(AIProviderService)
- group-report-service.ts values:采用 feat/report 版本(完整 hero/section 占位符)
- 整文件替换:ChatWindow.tsx / group-report.ts / mobile_daily_report.html
  (采用 feat/report 版本,与 group-report-facts.ts 协同)
2026-07-15 10:34:19 +08:00
电摇小子 556d70eab3 feat: 临时加一个windows 目录输入框 2026-07-14 14:33:40 +08:00
电摇小子 d079d45279 feat: 更换日报功能模板
- ChatWindow 模型下拉框新增 deepseek-v4-pro /
  deepseek-v4-flash
  - 主进程 model 兜底值改为 deepseek-v4-flash
  - 同步更新 .env.example 默认模型
  - 修复旧 localStorage 中 gpt-5.5 等不支持的模型导致的
  400 报错
2026-07-10 15:40:35 +08:00
226 changed files with 38071 additions and 6419 deletions
+7
View File
@@ -6,6 +6,9 @@ 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-chat
@@ -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
+76
View File
@@ -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
+8
View File
@@ -1,13 +1,21 @@
node_modules
*.tsbuildinfo
dist
out
.env
.DS_Store
.eslintcache
*.log*
coverage/
playwright-report/
test-results/
resources/connectors/wechat/
.omc
.codex/
docs/design/
docs/ui-redesign-plan.md
docs/ui-redesign-spec.md
AGENTS.md
findings.md
progress.md
task_plan.md
+351 -99
View File
@@ -1,171 +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.0`。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.7`
应用安装包:[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=群昵称` | 把昵称wxidmd5 解析成 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. 在“快速接入”中选择 CodexClaude Code。
4. 点击复制安装指令,将指令粘贴给对应 Agent 执行。
5. 安装完成后,可以直接向 Agent 提问:
> “今天技术交流群聊了什么?”
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。详细接口说明仍可查看 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。
### curl 调试示例(可选)
@@ -175,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)"
@@ -185,12 +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" />
</p>
+21 -2
View File
@@ -141,12 +141,31 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
"qa": [
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
],
"unresolved": [
{ "question": "待跟进问题", "owner": "相关人(可选)", "status": "待跟进", "note": "为什么还没结束" }
],
"storylines": [
{ "title": "剧情线", "stages": [{ "time": "10:12", "event": "提出问题" }], "result": "可选结果" }
],
"reversals": [
{ "topic": "某话题", "initialView": "最初判断", "finalView": "最终判断", "note": "可选说明" }
],
"participantChains": [
{ "topic": "某话题", "chain": ["A 提出", "B 补充", "C 收尾"], "note": "可选说明" }
],
"analytics": {
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
"activeTimeline": "10:00-12:00 为最活跃时段",
"topSpeakers": [{ "name": "张三", "count": 58 }]
"topSpeakers": [{ "name": "张三", "count": 58 }],
"voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
},
"keywords": ["高频词1", "高频词2"]
"keywords": ["高频词1", "高频词2"],
"hero": {
"headline": "一句抓重点的日报标题",
"summary": "一句概览",
"keyTakeaway": "最重要结论",
"pendingNote": "待跟进事项"
}
},
"metadata": {
"groupName": "技术交流",
+250
View File
@@ -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) 文末。
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

+2 -1
View File
@@ -16,6 +16,7 @@ extraMetadata:
asarUnpack:
- resources/**
extraResources:
# Includes the optional WeChat connector binary for the target platform.
- from: resources
to: resources
filter:
@@ -67,4 +68,4 @@ publish:
provider: github
owner: Wxw-Gu
repo: WechatExplorer
releaseType: draft
releaseType: release
+139
View File
@@ -0,0 +1,139 @@
"use strict";
const electron = require("electron");
const preload = require("@electron-toolkit/preload");
const api = {
writeAppLog: (entry) => electron.ipcRenderer.invoke("app-log:write", entry),
getAppLogPath: () => electron.ipcRenderer.invoke("app-log:getPath"),
revealAppLog: () => electron.ipcRenderer.invoke("app-log:reveal"),
getAppUpdateState: () => electron.ipcRenderer.invoke("app-update:getState"),
checkAppUpdate: () => electron.ipcRenderer.invoke("app-update:check"),
downloadAppUpdate: () => electron.ipcRenderer.invoke("app-update:download"),
installAppUpdate: () => electron.ipcRenderer.invoke("app-update:install"),
onAppUpdateState: (callback) => {
const listener = (_event, state) => callback(state);
electron.ipcRenderer.on("app-update:state", listener);
return () => electron.ipcRenderer.removeListener("app-update:state", listener);
},
getCacheSummary: () => electron.ipcRenderer.invoke("cache:getSummary"),
clearCache: (scope) => electron.ipcRenderer.invoke("cache:clear", scope),
initDb: (key) => electron.ipcRenderer.invoke("db:init", key),
getBootstrapCache: () => electron.ipcRenderer.invoke("db:getBootstrapCache"),
getStartupCache: () => electron.ipcRenderer.invoke("db:getStartupCache"),
getContacts: (filter) => electron.ipcRenderer.invoke("db:getContacts", filter),
getContactAvatars: (usernames) => electron.ipcRenderer.invoke("db:getContactAvatars", usernames),
getCachedMessages: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessages", userMd5, startTime, endTime),
getCachedMessagePage: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessagePage", userMd5, startTime, endTime),
getMessages: (userMd5, startTime, endTime, options) => electron.ipcRenderer.invoke("db:getMessages", userMd5, startTime, endTime, options),
getGroupSnapshot: (userMd5) => electron.ipcRenderer.invoke("db:getGroupSnapshot", userMd5),
search: (keyword) => electron.ipcRenderer.invoke("db:search", keyword),
aiChat: (messages, options) => electron.ipcRenderer.invoke("ai:chat", messages, options),
listAIProviders: () => electron.ipcRenderer.invoke("ai:listProviders"),
getAIRuntimeConfig: () => electron.ipcRenderer.invoke("ai:getRuntimeConfig"),
saveAIProvider: (provider) => electron.ipcRenderer.invoke("ai:saveProvider", provider),
deleteAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:deleteProvider", providerId),
setDefaultAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:setDefaultProvider", providerId),
testAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:testProvider", providerId),
testAIVision: (request) => electron.ipcRenderer.invoke("ai:testVision", request),
migrateLegacyAIConfig: (config) => electron.ipcRenderer.invoke("ai:migrateLegacy", config),
copyImage: (base64String) => electron.ipcRenderer.invoke("copy-image", base64String),
getVoiceData: (sessionId, localId, createTime, svrId) => electron.ipcRenderer.invoke("db:getVoiceData", sessionId, localId, createTime, svrId),
parseMessage: (content, messageType) => electron.ipcRenderer.invoke("db:parseMessage", content, messageType),
getImage: (imageMd5, imageDatNameOrThumb, sessionId, options) => electron.ipcRenderer.invoke("db:getImage", imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes) => electron.ipcRenderer.invoke("db:getVideo", hashes),
getSticker: (cdnUrl, md5) => electron.ipcRenderer.invoke("db:getSticker", cdnUrl, md5),
startExport: (request) => electron.ipcRenderer.invoke("export:start", request),
cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId),
revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path),
onExportProgress: (callback) => {
const listener = (_event, progress) => callback(progress);
electron.ipcRenderer.on("export:progress", listener);
return () => electron.ipcRenderer.removeListener("export:progress", listener);
},
exportGroupReport: (request) => electron.ipcRenderer.invoke("report:export", request),
listGeneratedReports: () => electron.ipcRenderer.invoke("report:listGenerated"),
saveGeneratedReport: (request) => electron.ipcRenderer.invoke("report:saveGenerated", request),
deleteGeneratedReport: (reportId) => electron.ipcRenderer.invoke("report:deleteGenerated", reportId),
revealGroupReport: (filePath) => electron.ipcRenderer.invoke("report:reveal", filePath),
getSavedDbKey: () => electron.ipcRenderer.invoke("key:getSavedDbKey"),
getDatabaseKeyEnvironment: () => electron.ipcRenderer.invoke("key:getEnvironment"),
readDatabaseKeyClipboard: () => electron.ipcRenderer.invoke("key:readClipboardDbKey"),
autoGetDbKey: (options) => electron.ipcRenderer.invoke("key:autoGetDbKey", options),
autoGetImageKey: (options) => electron.ipcRenderer.invoke("key:autoGetImageKey", options),
getImageKeyConfig: () => electron.ipcRenderer.invoke("image:getConfig"),
getImageDecryptionStatus: () => electron.ipcRenderer.invoke("image:getStatus"),
saveImageKeyConfig: (request) => electron.ipcRenderer.invoke("image:saveConfig", request),
testImageDecryption: (request) => electron.ipcRenderer.invoke("image:testConfig", request),
clearImageKeyConfig: () => electron.ipcRenderer.invoke("image:clearConfig"),
pasteAndSaveDbKey: () => electron.ipcRenderer.invoke("key:pasteAndSaveDbKey"),
saveDbKey: (key) => electron.ipcRenderer.invoke("key:saveDbKey", key),
clearSavedDbKey: () => electron.ipcRenderer.invoke("key:clearSavedDbKey"),
onWcdbChange: (callback) => {
const listener = (_event, payload) => callback(payload);
electron.ipcRenderer.on("wcdb-change", listener);
return () => electron.ipcRenderer.removeListener("wcdb-change", listener);
},
onDbKeyStatus: (callback) => {
const listener = (_event, payload) => callback(payload);
electron.ipcRenderer.on("key:dbKeyStatus", listener);
return () => electron.ipcRenderer.removeListener("key:dbKeyStatus", listener);
},
onImageKeyStatus: (callback) => {
const listener = (_event, payload) => callback(payload);
electron.ipcRenderer.on("key:imageKeyStatus", listener);
return () => electron.ipcRenderer.removeListener("key:imageKeyStatus", listener);
},
getSettings: () => electron.ipcRenderer.invoke("settings:get"),
setSettings: (patch) => electron.ipcRenderer.invoke("settings:set", patch),
getSelf: () => electron.ipcRenderer.invoke("settings:getSelf"),
testConnection: (key, accountRoot) => electron.ipcRenderer.invoke("db:testConnection", key, accountRoot),
reopenWithRoot: (accountRoot) => electron.ipcRenderer.invoke("db:reopenWithRoot", accountRoot),
selectDbRoot: () => electron.ipcRenderer.invoke("settings:selectDbRoot"),
openAccountRoot: () => electron.ipcRenderer.invoke("settings:openAccountRoot"),
disconnectDb: (options) => electron.ipcRenderer.invoke("db:disconnect", options),
apiStatus: () => electron.ipcRenderer.invoke("api:getStatus"),
apiStart: (host, port) => electron.ipcRenderer.invoke("api:start", host, port),
apiStop: () => electron.ipcRenderer.invoke("api:stop"),
apiToggle: (enabled) => electron.ipcRenderer.invoke("api:toggle", enabled),
getReaderSkillStatus: () => electron.ipcRenderer.invoke("api:skillStatus"),
readReaderSkill: () => electron.ipcRenderer.invoke("api:readSkill"),
revealReaderSkill: () => electron.ipcRenderer.invoke("api:revealSkill"),
openReaderSkillGithub: () => electron.ipcRenderer.invoke("api:openSkillGithub"),
testLocalApiRequest: (request) => electron.ipcRenderer.invoke("api:testLocalRequest", request),
copyText: (text) => electron.ipcRenderer.invoke("api:copyText", text),
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
// ============================================================
imageListCandidates: (query) => electron.ipcRenderer.invoke("image:listCandidates", query),
imageAnalyze: (request) => electron.ipcRenderer.invoke("image:analyze", request),
getImageInsight: (imageHash) => electron.ipcRenderer.invoke("image:getInsight", imageHash),
listImageInsights: (sessionId, limit) => electron.ipcRenderer.invoke("image:listInsights", sessionId, limit),
getAgentHubStatus: () => electron.ipcRenderer.invoke("agent-hub:getStatus"),
getAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:getLogs"),
clearAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:clearLogs"),
startAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:startLogin"),
cancelAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:cancelLogin"),
reconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:reconnect"),
disconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:disconnect"),
selectAgentHubTestImage: () => electron.ipcRenderer.invoke("agent-hub:selectTestImage"),
onAgentHubStatus: (callback) => {
const listener = (_event, status) => callback(status);
electron.ipcRenderer.on("agent-hub:status", listener);
return () => electron.ipcRenderer.removeListener("agent-hub:status", listener);
},
onAgentHubLog: (callback) => {
const listener = (_event, entry) => callback(entry);
electron.ipcRenderer.on("agent-hub:log", listener);
return () => electron.ipcRenderer.removeListener("agent-hub:log", listener);
}
};
if (process.contextIsolated) {
try {
electron.contextBridge.exposeInMainWorld("electron", preload.electronAPI);
electron.contextBridge.exposeInMainWorld("api", api);
} catch (error) {
console.error(error);
}
} else {
window.electron = preload.electronAPI;
window.api = api;
}
+49 -9
View File
@@ -1,6 +1,6 @@
{
"name": "wechatexplorer",
"version": "2.1.1",
"version": "2.1.7",
"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",
@@ -25,23 +26,43 @@
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
"cp:env": "node scripts/ensure-env.cjs",
"prepare:env": "node scripts/ensure-env.cjs",
"predev": "node scripts/ensure-env.cjs",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"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",
"build:native-services": "npm run build:wechat-connector",
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run build && electron-builder --config electron-builder.yml --win",
"build:mac:x64": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"build:mac:arm64": "electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"release:mac": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
"build:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64",
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"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": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@tanstack/react-virtual": "^3.14.6",
"cross-env": "^10.1.0",
"electron-updater": "^6.6.2",
"fs-extra": "^11.3.2",
"fzstd": "^0.1.1",
"jsonrepair": "^3.15.0",
"koffi": "^3.1.0",
"openai": "^6.10.0",
"silk-wasm": "^3.7.1",
@@ -51,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",
@@ -64,13 +91,26 @@
"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": {
"os": [
"current",
"win32"
],
"cpu": [
"current",
"x64"
]
},
"onlyBuiltDependencies": [
"electron",
"esbuild"
+22
View File
@@ -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'
}
})
+1053 -20
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+449 -108
View File
@@ -27,13 +27,13 @@
.report {
width: 430px;
margin: 0 auto;
padding: 22px 14px 34px;
padding: 20px 14px 34px;
}
.hero,
.card,
.section {
.section,
.card {
background: #fff;
border-radius: 16px;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
@@ -41,32 +41,35 @@
}
.hero-top {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
min-width: 0;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.hero h1 {
margin: 0;
font-size: 23px;
line-height: 1.2;
margin: 0 0 8px;
font-weight: 900;
}
.sub {
margin-top: 8px;
color: #667085;
font-size: 13px;
line-height: 1.5;
line-height: 1.55;
}
.record-note {
color: #485465;
font-weight: 650;
}
.overview {
margin-top: 2px;
.mode-tag {
display: inline-flex;
margin-top: 10px;
padding: 4px 10px;
border-radius: 999px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
font-weight: 800;
}
.avatar-grid {
width: 58px;
@@ -83,6 +86,49 @@
border-radius: 50%;
object-fit: cover;
}
.hero-headline {
margin-top: 14px;
padding: 14px;
border-radius: 16px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
}
.hero-headline b {
display: block;
font-size: 17px;
color: #076c39;
}
.hero-headline p {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.65;
color: #1f2933;
}
.hero-inline-notes {
display: grid;
gap: 8px;
margin-top: 10px;
}
.hero-note,
.hero-status {
padding: 10px 12px;
border-radius: 12px;
font-size: 12px;
line-height: 1.5;
}
.hero-status {
margin-top: 10px;
background: #f7faf9;
color: #076c39;
font-weight: 700;
}
.hero-note.takeaway {
background: #eef8f2;
color: #076c39;
}
.hero-note.pending {
background: #fff8e8;
color: #8a5a00;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -94,14 +140,11 @@
border-radius: 12px;
padding: 10px 6px;
text-align: center;
min-width: 0;
overflow: hidden;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
white-space: nowrap;
line-height: 1.25;
}
.stat span {
@@ -109,16 +152,16 @@
color: #667085;
}
.section {
margin-top: 14px;
margin-top: 18px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
margin-bottom: 14px;
font-size: 19px;
font-weight: 900;
margin-bottom: 12px;
}
.section-title::before {
content: '';
@@ -127,11 +170,23 @@
border-radius: 99px;
background: #07c160;
}
.section-subtitle {
margin: 10px 0 6px;
color: #667085;
font-size: 12px;
font-weight: 700;
}
.section-more {
margin-top: 10px;
color: #98a2b3;
font-size: 11px;
text-align: right;
}
.card {
padding: 14px;
margin-top: 10px;
box-shadow: none;
border: 1px solid #edf0f2;
box-shadow: none;
}
.topic-title-row {
display: flex;
@@ -140,17 +195,17 @@
gap: 8px;
}
.topic-title-row h3 {
margin: 0;
font-size: 16px;
line-height: 1.35;
margin: 0;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
border-radius: 999px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
@@ -165,19 +220,44 @@
background: #eef5ff;
color: #1677ff;
}
.red {
background: #fff1f0;
color: #ff4d4f;
}
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.65;
margin: 10px 0 0;
}
.topic-conclusions {
display: grid;
gap: 8px;
margin-top: 10px;
}
.topic-conclusion {
padding: 8px 10px;
border-radius: 10px;
background: #edf9f1;
color: #076c39;
font-size: 12px;
line-height: 1.5;
font-weight: 700;
}
.topic-inline-image {
display: grid;
grid-template-columns: 76px 1fr;
gap: 10px;
margin-top: 10px;
padding: 10px;
border-radius: 12px;
background: #f7faf9;
}
.topic-inline-image img {
width: 76px;
height: 76px;
border-radius: 10px;
object-fit: cover;
}
.participants {
display: flex;
@@ -189,7 +269,6 @@
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
@@ -198,7 +277,6 @@
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.person-chip b {
max-width: 58px;
@@ -220,17 +298,6 @@
background: #f2f4f7;
color: #667085;
}
.resource {
padding: 11px 12px;
background: #f7f8fa;
border-radius: 12px;
margin-top: 8px;
font-size: 13px;
line-height: 1.55;
}
.resource b {
color: #1677ff;
}
.important-card {
display: flex;
gap: 10px;
@@ -255,9 +322,9 @@
color: #1f2933;
}
.important-text {
margin-top: 5px;
font-size: 13px;
line-height: 1.55;
margin-top: 5px;
}
.important-note {
margin-top: 8px;
@@ -269,6 +336,34 @@
font-size: 12px;
line-height: 1.45;
}
.action-grid {
display: grid;
gap: 10px;
}
.action-card {
border-radius: 14px;
padding: 12px;
}
.todo-card {
background: #eef5ff;
}
.unresolved-card {
background: #fff8e8;
}
.action-card b {
display: block;
color: #1f2933;
font-size: 14px;
}
.action-card div {
margin-top: 6px;
font-size: 12px;
line-height: 1.55;
color: #485465;
}
.action-note {
color: #667085;
}
.chat-block {
background: #f0f2f5;
border-radius: 14px;
@@ -299,49 +394,180 @@
line-height: 1.5;
}
.quote-note {
background: #fff8e1;
border-radius: 10px;
padding: 9px 10px;
margin-top: 10px;
padding: 9px 10px;
border-radius: 10px;
background: #fff8e1;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.qa-card {
background: #f8fafc;
border-radius: 14px;
padding: 12px;
.qa-card,
.resource {
margin-top: 10px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.qa-card b {
.qa-card b,
.resource b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
.qa-card div {
.qa-card div,
.resource {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
.bar-row {
.storyline-card,
.chain-card {
background: #f8fafc;
}
.storyline-steps {
display: grid;
grid-template-columns: 82px 1fr;
gap: 8px;
align-items: center;
margin-top: 9px;
margin-top: 10px;
}
.storyline-step {
display: grid;
grid-template-columns: 50px 1fr;
gap: 10px;
}
.storyline-step span {
color: #8a94a6;
font-size: 12px;
}
.bar {
height: 10px;
background: #edf1f5;
border-radius: 999px;
overflow: hidden;
.storyline-step b {
font-size: 13px;
line-height: 1.5;
}
.bar i {
display: block;
height: 100%;
background: #07c160;
.chain-flow {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 10px;
}
.chain-flow span {
display: inline-flex;
align-items: center;
padding: 6px 9px;
border-radius: 999px;
background: #eef8f2;
color: #076c39;
font-size: 12px;
font-weight: 700;
}
.chain-flow i {
font-style: normal;
color: #98a2b3;
}
.gallery-card {
display: grid;
grid-template-columns: 112px 1fr;
gap: 12px;
margin-top: 10px;
padding: 12px;
background: #f7faf9;
border-radius: 14px;
}
.gallery-image {
width: 112px;
height: 112px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.gallery-stats {
display: inline-flex;
margin-top: 7px;
padding: 4px 8px;
border-radius: 999px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
font-weight: 700;
}
.badge-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
/* AI 图片识别板块 */
.vision-card {
display: grid;
grid-template-columns: 132px 1fr;
gap: 14px;
margin-top: 10px;
padding: 12px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
border: 1px solid #d6efde;
border-radius: 14px;
}
.vision-image {
width: 132px;
height: 132px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.vision-description {
margin-top: 6px;
font-size: 13px;
line-height: 1.5;
color: #1f2933;
}
.vision-ocr {
margin-top: 6px;
padding: 6px 10px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
border-radius: 8px;
word-break: break-all;
}
.vision-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.vision-tag {
display: inline-block;
padding: 3px 8px;
background: #07c160;
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 999px;
}
.vision-label {
margin-top: 6px;
font-size: 10px;
color: #07a352;
font-weight: 600;
}
.badge-card {
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
border: 1px solid #edf0f2;
border-radius: 14px;
padding: 12px;
}
.badge-card b {
display: block;
margin-top: 8px;
font-size: 15px;
}
.badge-card p {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.55;
}
.data-grid {
display: grid;
gap: 12px;
}
.rank {
display: flex;
@@ -350,6 +576,9 @@
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank:last-child {
border-bottom: none;
}
.rank img {
width: 30px;
height: 30px;
@@ -402,7 +631,58 @@
color: #8a94a6;
}
.empty-section {
display: none;
display: none !important;
}
.compact .report {
padding-top: 18px;
}
.compact .section {
margin-top: 12px;
padding: 14px;
}
.compact .card {
padding: 11px;
}
.compact .important-card,
.compact .gallery-card,
.compact .chat-block {
padding: 10px;
}
.compact .section-title {
margin-bottom: 9px;
font-size: 17px;
}
.compact .hero-headline p,
.compact .card p,
.compact .important-text,
.compact .chat-bubble {
line-height: 1.5;
}
.compact .participants,
.compact .keywords,
.compact .hero-inline-notes {
gap: 6px;
}
.compact .topic-conclusions {
gap: 6px;
}
.compact .topic-inline-image {
grid-template-columns: 64px 1fr;
padding: 8px;
}
.compact .topic-inline-image img {
width: 64px;
height: 64px;
}
.compact .stats {
gap: 6px;
margin-top: 14px;
}
.compact .stat {
padding: 8px 6px;
}
.compact .stat b {
font-size: 17px;
}
@media (max-width: 430px) {
html,
@@ -414,83 +694,144 @@
padding-left: 12px;
padding-right: 12px;
}
.stats {
gap: 6px;
}
.stat b {
font-size: 16px;
}
}
</style>
</head>
<body>
<body class="{{REPORT_MODE_CLASS}}">
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">
<div>{{DATE_RANGE}}</div>
<div class="record-note">{{RECORD_NOTE}}</div>
<div class="overview">{{OVERVIEW}}</div>
</div>
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
<div class="mode-tag">{{REPORT_MODE_LABEL}}</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
</div>
<div class="hero-headline">
<b>{{HERO_HEADLINE}}</b>
<p>{{HERO_SUMMARY}}</p>
</div>
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
<div class="hero-inline-notes">
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
</div>
</header>
<section class="section topics">
<section class="section {{TOPICS_EMPTY_CLASS}}">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
{{TOPICS_MORE_NOTE}}
</section>
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
<section class="section {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息</div>
{{IMPORTANT_MESSAGES}}
{{MESSAGES_MORE_NOTE}}
</section>
<section class="section {{ACTIONS_EMPTY_CLASS}}">
<div class="section-title">待办事项和未解决问题</div>
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
{{ACTIONS_MORE_NOTE}}
</section>
<section class="section {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">今日名场面</div>
{{QUOTE_BLOCKS}}
{{QUOTES_MORE_NOTE}}
</section>
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
<div class="section-title">今日群数据</div>
<div class="data-grid">
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
</div>
</div>
</section>
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
<div class="section-title">关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
{{KEYWORDS_MORE_NOTE}}
</section>
<section class="section {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
{{RESOURCES_MORE_NOTE}}
</section>
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息汇总</div>
{{IMPORTANT_MESSAGES}}
</section>
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">有趣对话或金句</div>
{{QUOTE_BLOCKS}}
</section>
<section class="section qa {{QA_EMPTY_CLASS}}">
<section class="section {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
{{QA_MORE_NOTE}}
</section>
<section class="section analytics">
<div class="section-title">群内数据可视化</div>
{{HEAT_BARS}}
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
话唠榜 TOP5(基于已读取记录估算)
</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
</div>
<section class="section {{STORYLINES_EMPTY_CLASS}}">
<div class="section-title">今日剧情时间线</div>
{{STORYLINE_CARDS}}
{{STORYLINES_MORE_NOTE}}
</section>
<section class="section cloud">
<div class="section-title">词云/关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
<section class="section {{REVERSALS_EMPTY_CLASS}}">
<div class="section-title">群聊反转现场</div>
{{REVERSAL_CARDS}}
{{REVERSALS_MORE_NOTE}}
</section>
<section class="section {{VISION_EMPTY_CLASS}}">
<div class="section-title">{{VISION_TITLE}}</div>
{{VISION_CARDS}}
</section>
<section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div>
{{GALLERY_CARDS}}
{{GALLERY_MORE_NOTE}}
</section>
<section class="section {{VOICE_EMPTY_CLASS}}">
<div class="section-title">语音之最</div>
{{VOICE_CARDS}}
{{VOICE_MORE_NOTE}}
</section>
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
<div class="section-title">语音时长榜</div>
<div class="card">{{VOICE_RANK_CARDS}}</div>
</section>
<section class="section {{BADGES_EMPTY_CLASS}}">
<div class="section-title">今日临时人设</div>
<div class="badge-grid">{{BADGE_CARDS}}</div>
{{BADGES_MORE_NOTE}}
</section>
<section class="section {{CHAINS_EMPTY_CLASS}}">
<div class="section-title">话题参与链路</div>
{{CHAIN_CARDS}}
{{CHAINS_MORE_NOTE}}
</section>
<footer class="footer">
数据来源:WechatExplorer · 微信群聊记录<br />
数据来源:微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
+592
View File
@@ -0,0 +1,592 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{REPORT_TITLE}}</title>
<style>
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 0;
height: 0;
}
html {
width: 430px;
scrollbar-width: none;
}
body {
margin: 0;
width: 430px;
background: #f3f5f7;
color: #1f2933;
font-family:
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.report {
width: 430px;
margin: 0 auto;
padding: 22px 14px 34px;
}
.hero,
.card,
.section {
background: #fff;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
padding: 20px;
}
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.hero h1 {
font-size: 23px;
line-height: 1.2;
margin: 0 0 8px;
font-weight: 900;
}
.sub {
color: #667085;
font-size: 13px;
line-height: 1.5;
}
.record-note {
color: #485465;
font-weight: 650;
}
.overview {
margin-top: 2px;
}
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
flex: 0 0 auto;
}
.avatar-grid img,
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 16px;
}
.stat {
background: #f7faf9;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
min-width: 0;
overflow: hidden;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
white-space: nowrap;
line-height: 1.25;
}
.stat span {
font-size: 11px;
color: #667085;
}
.section {
margin-top: 14px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 900;
margin-bottom: 12px;
}
.section-title::before {
content: '';
width: 5px;
height: 20px;
border-radius: 99px;
background: #07c160;
}
.card {
padding: 14px;
margin-top: 10px;
box-shadow: none;
border: 1px solid #edf0f2;
}
.topic-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.topic-title-row h3 {
font-size: 16px;
line-height: 1.35;
margin: 0;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.hot {
background: #fff4e5;
color: #d46b08;
}
.blue {
background: #eef5ff;
color: #1677ff;
}
.red {
background: #fff1f0;
color: #ff4d4f;
}
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
font-size: 13px;
line-height: 1.65;
margin: 10px 0 0;
}
.participants {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
}
.person-chip img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.person-chip b {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.keywords span {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: #f2f4f7;
color: #667085;
}
.resource {
padding: 11px 12px;
background: #f7f8fa;
border-radius: 12px;
margin-top: 8px;
font-size: 13px;
line-height: 1.55;
}
.resource b {
color: #1677ff;
}
.important-card {
display: flex;
gap: 10px;
background: #f7faf9;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.important-card > .avatar {
width: 36px;
height: 36px;
flex: 0 0 auto;
}
.important-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #667085;
}
.important-meta b {
color: #1f2933;
}
.important-text {
font-size: 13px;
line-height: 1.55;
margin-top: 5px;
}
.important-note {
margin-top: 8px;
padding: 7px 9px;
border-left: 3px solid #07c160;
background: #fff;
border-radius: 8px;
color: #07a352;
font-size: 12px;
line-height: 1.45;
}
.chat-block {
background: #f0f2f5;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.chat-msg {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.chat-name {
font-size: 11px;
color: #667085;
margin-bottom: 4px;
}
.chat-bubble {
background: #fff;
border-radius: 4px 12px 12px 12px;
padding: 9px 10px;
font-size: 13px;
line-height: 1.5;
}
.quote-note {
background: #fff8e1;
border-radius: 10px;
padding: 9px 10px;
margin-top: 10px;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.qa-card {
background: #f8fafc;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.qa-card b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
.qa-card div {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
.bar-row {
display: grid;
grid-template-columns: 82px 1fr;
gap: 8px;
align-items: center;
margin-top: 9px;
font-size: 12px;
}
.bar {
height: 10px;
background: #edf1f5;
border-radius: 999px;
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: #07c160;
border-radius: 999px;
}
.rank {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank img {
width: 30px;
height: 30px;
border-radius: 50%;
object-fit: cover;
}
.rank b {
font-size: 13px;
}
.rank span {
margin-left: auto;
color: #8a94a6;
font-size: 12px;
}
.cloud-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cloud-tags span {
padding: 6px 10px;
border-radius: 999px;
background: #f2f4f7;
color: #485465;
font-weight: 800;
}
.cloud-tags .xl {
font-size: 20px;
color: #07a352;
background: #e9f8ef;
}
.cloud-tags .lg {
font-size: 17px;
color: #1677ff;
background: #eef5ff;
}
.cloud-tags .md {
font-size: 15px;
color: #d46b08;
background: #fff4e5;
}
.footer {
padding: 16px 4px 0;
color: #98a2b3;
text-align: center;
font-size: 11px;
line-height: 1.8;
}
.muted {
color: #8a94a6;
}
.empty-section {
display: none;
}
@media (max-width: 430px) {
html,
body {
width: 100%;
}
.report {
width: 100%;
padding-left: 12px;
padding-right: 12px;
}
.stats {
gap: 6px;
}
.stat b {
font-size: 16px;
}
}
/* AI 图片识别板块(v1 模板) */
.vision-card {
display: grid;
grid-template-columns: 132px 1fr;
gap: 14px;
margin-top: 10px;
padding: 12px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
border: 1px solid #d6efde;
border-radius: 14px;
}
.vision-image {
width: 132px;
height: 132px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.vision-description {
margin-top: 6px;
font-size: 13px;
line-height: 1.5;
color: #1f2933;
}
.vision-ocr {
margin-top: 6px;
padding: 6px 10px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
border-radius: 8px;
word-break: break-all;
}
.vision-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.vision-tag {
display: inline-block;
padding: 3px 8px;
background: #07c160;
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 999px;
}
.vision-label {
margin-top: 6px;
font-size: 10px;
color: #07a352;
font-weight: 600;
}
/* 热度条形图(v1 模板) */
.heat-row {
display: grid;
grid-template-columns: 80px 1fr 40px;
align-items: center;
gap: 10px;
margin-top: 8px;
font-size: 12px;
}
.heat-name {
color: #1f2933;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.heat-bar {
background: #f3f5f7;
border-radius: 999px;
height: 10px;
overflow: hidden;
}
.heat-bar i {
display: block;
height: 100%;
background: linear-gradient(90deg, #07c160 0%, #34d399 100%);
border-radius: 999px;
}
.heat-val {
color: #485465;
font-weight: 700;
font-size: 11px;
text-align: right;
}
</style>
</head>
<body>
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">
<div>{{DATE_RANGE}}</div>
<div class="record-note">{{RECORD_NOTE}}</div>
<div class="overview">{{OVERVIEW}}</div>
</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
</div>
</header>
<section class="section topics">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
</section>
<section class="section vision {{VISION_EMPTY_CLASS}}">
<div class="section-title">{{VISION_TITLE}}</div>
{{VISION_CARDS}}
</section>
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
</section>
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息汇总</div>
{{IMPORTANT_MESSAGES}}
</section>
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">有趣对话或金句</div>
{{QUOTE_BLOCKS}}
</section>
<section class="section qa {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
</section>
<section class="section analytics">
<div class="section-title">群内数据可视化</div>
{{HEAT_BARS}}
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
话唠榜 TOP5(基于已读取记录估算)
</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
</div>
</section>
<section class="section cloud">
<div class="section-title">词云/关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
</section>
<footer class="footer">
数据来源:WechatExplorer · 微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
</main>
</body>
</html>
+829
View File
@@ -0,0 +1,829 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{REPORT_TITLE}}</title>
<style>
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 0;
height: 0;
}
html {
width: 430px;
scrollbar-width: none;
}
body {
margin: 0;
width: 430px;
background: #f3f5f7;
color: #1f2933;
font-family:
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.report {
width: 430px;
margin: 0 auto;
padding: 20px 14px 34px;
}
.hero,
.section,
.card {
background: #fff;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
padding: 20px;
}
.hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.hero h1 {
margin: 0;
font-size: 23px;
line-height: 1.2;
font-weight: 900;
}
.sub {
margin-top: 8px;
color: #667085;
font-size: 13px;
line-height: 1.55;
}
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
flex: 0 0 auto;
}
.avatar-grid img,
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.hero-headline {
margin-top: 14px;
padding: 14px;
border-radius: 16px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
}
.hero-headline b {
display: block;
font-size: 17px;
color: #076c39;
}
.hero-headline p {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.65;
color: #1f2933;
}
.hero-inline-notes {
display: grid;
gap: 8px;
margin-top: 10px;
}
.hero-note,
.hero-status {
padding: 10px 12px;
border-radius: 12px;
font-size: 12px;
line-height: 1.5;
}
.hero-status {
margin-top: 10px;
background: #f7faf9;
color: #076c39;
font-weight: 700;
}
.hero-note.takeaway {
background: #eef8f2;
color: #076c39;
}
.hero-note.pending {
background: #fff8e8;
color: #8a5a00;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 16px;
}
.stat {
background: #f7faf9;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
line-height: 1.25;
}
.stat span {
font-size: 11px;
color: #667085;
}
.section {
margin-top: 18px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-size: 19px;
font-weight: 900;
}
.section-title::before {
content: '';
width: 5px;
height: 20px;
border-radius: 99px;
background: #07c160;
}
.section-subtitle {
margin: 10px 0 6px;
color: #667085;
font-size: 12px;
font-weight: 700;
}
.section-more {
margin-top: 10px;
color: #98a2b3;
font-size: 11px;
text-align: right;
}
.card {
padding: 14px;
margin-top: 10px;
border: 1px solid #edf0f2;
box-shadow: none;
}
.topic-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.topic-title-row h3 {
margin: 0;
font-size: 16px;
line-height: 1.35;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.hot {
background: #fff4e5;
color: #d46b08;
}
.blue {
background: #eef5ff;
color: #1677ff;
}
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.65;
}
.topic-conclusions {
display: grid;
gap: 8px;
margin-top: 10px;
}
.topic-conclusion {
padding: 8px 10px;
border-radius: 10px;
background: #edf9f1;
color: #076c39;
font-size: 12px;
line-height: 1.5;
font-weight: 700;
}
.topic-inline-image {
display: grid;
grid-template-columns: 76px 1fr;
gap: 10px;
margin-top: 10px;
padding: 10px;
border-radius: 12px;
background: #f7faf9;
}
.topic-inline-image img {
width: 76px;
height: 76px;
border-radius: 10px;
object-fit: cover;
}
.participants {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 5px;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
}
.person-chip img {
width: 24px;
height: 24px;
border-radius: 50%;
}
.person-chip b {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.keywords span {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: #f2f4f7;
color: #667085;
}
.important-card {
display: flex;
gap: 10px;
background: #f7faf9;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.important-card > .avatar {
width: 36px;
height: 36px;
flex: 0 0 auto;
}
.important-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #667085;
}
.important-meta b {
color: #1f2933;
}
.important-text {
margin-top: 5px;
font-size: 13px;
line-height: 1.55;
}
.important-note {
margin-top: 8px;
padding: 7px 9px;
border-left: 3px solid #07c160;
background: #fff;
border-radius: 8px;
color: #07a352;
font-size: 12px;
line-height: 1.45;
}
.action-grid {
display: grid;
gap: 10px;
}
.action-card {
border-radius: 14px;
padding: 12px;
}
.todo-card {
background: #eef5ff;
}
.unresolved-card {
background: #fff8e8;
}
.action-card b {
display: block;
color: #1f2933;
font-size: 14px;
}
.action-card div {
margin-top: 6px;
font-size: 12px;
line-height: 1.55;
color: #485465;
}
.action-note {
color: #667085;
}
.chat-block {
background: #f0f2f5;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.chat-msg {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.chat-name {
font-size: 11px;
color: #667085;
margin-bottom: 4px;
}
.chat-bubble {
background: #fff;
border-radius: 4px 12px 12px 12px;
padding: 9px 10px;
font-size: 13px;
line-height: 1.5;
}
.quote-note {
margin-top: 10px;
padding: 9px 10px;
border-radius: 10px;
background: #fff8e1;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.qa-card,
.resource {
margin-top: 10px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.qa-card b,
.resource b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
.qa-card div,
.resource {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
.storyline-card,
.chain-card {
background: #f8fafc;
}
.storyline-steps {
display: grid;
gap: 8px;
margin-top: 10px;
}
.storyline-step {
display: grid;
grid-template-columns: 50px 1fr;
gap: 10px;
}
.storyline-step span {
color: #8a94a6;
font-size: 12px;
}
.storyline-step b {
font-size: 13px;
line-height: 1.5;
}
.chain-flow {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 10px;
}
.chain-flow span {
display: inline-flex;
align-items: center;
padding: 6px 9px;
border-radius: 999px;
background: #eef8f2;
color: #076c39;
font-size: 12px;
font-weight: 700;
}
.chain-flow i {
font-style: normal;
color: #98a2b3;
}
.gallery-card {
display: grid;
grid-template-columns: 112px 1fr;
gap: 12px;
margin-top: 10px;
padding: 12px;
background: #f7faf9;
border-radius: 14px;
}
.gallery-image {
width: 112px;
height: 112px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.gallery-stats {
display: inline-flex;
margin-top: 7px;
padding: 4px 8px;
border-radius: 999px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
font-weight: 700;
}
.badge-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
/* AI 图片识别板块 */
.vision-card {
display: grid;
grid-template-columns: 132px 1fr;
gap: 14px;
margin-top: 10px;
padding: 12px;
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
border: 1px solid #d6efde;
border-radius: 14px;
}
.vision-image {
width: 132px;
height: 132px;
border-radius: 12px;
object-fit: cover;
background: #e5e7eb;
}
.vision-description {
margin-top: 6px;
font-size: 13px;
line-height: 1.5;
color: #1f2933;
}
.vision-ocr {
margin-top: 6px;
padding: 6px 10px;
background: #eef5ff;
color: #1677ff;
font-size: 11px;
border-radius: 8px;
word-break: break-all;
}
.vision-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.vision-tag {
display: inline-block;
padding: 3px 8px;
background: #07c160;
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 999px;
}
.vision-label {
margin-top: 6px;
font-size: 10px;
color: #07a352;
font-weight: 600;
}
.badge-card {
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
border: 1px solid #edf0f2;
border-radius: 14px;
padding: 12px;
}
.badge-card b {
display: block;
margin-top: 8px;
font-size: 15px;
}
.badge-card p {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.55;
}
.data-grid {
display: grid;
gap: 12px;
}
.rank {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank:last-child {
border-bottom: none;
}
.rank img {
width: 30px;
height: 30px;
border-radius: 50%;
object-fit: cover;
}
.rank b {
font-size: 13px;
}
.rank span {
margin-left: auto;
color: #8a94a6;
font-size: 12px;
}
.cloud-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cloud-tags span {
padding: 6px 10px;
border-radius: 999px;
background: #f2f4f7;
color: #485465;
font-weight: 800;
}
.cloud-tags .xl {
font-size: 20px;
color: #07a352;
background: #e9f8ef;
}
.cloud-tags .lg {
font-size: 17px;
color: #1677ff;
background: #eef5ff;
}
.cloud-tags .md {
font-size: 15px;
color: #d46b08;
background: #fff4e5;
}
.footer {
padding: 16px 4px 0;
color: #98a2b3;
text-align: center;
font-size: 11px;
line-height: 1.8;
}
.muted {
color: #8a94a6;
}
.empty-section {
display: none !important;
}
.compact .report {
padding-top: 18px;
}
.compact .section {
margin-top: 12px;
padding: 14px;
}
.compact .card {
padding: 11px;
}
.compact .important-card,
.compact .gallery-card,
.compact .chat-block {
padding: 10px;
}
.compact .section-title {
margin-bottom: 9px;
font-size: 17px;
}
.compact .hero-headline p,
.compact .card p,
.compact .important-text,
.compact .chat-bubble {
line-height: 1.5;
}
.compact .participants,
.compact .keywords,
.compact .hero-inline-notes {
gap: 6px;
}
.compact .topic-conclusions {
gap: 6px;
}
.compact .topic-inline-image {
grid-template-columns: 64px 1fr;
padding: 8px;
}
.compact .topic-inline-image img {
width: 64px;
height: 64px;
}
.compact .stats {
gap: 6px;
margin-top: 14px;
}
.compact .stat {
padding: 8px 6px;
}
.compact .stat b {
font-size: 17px;
}
@media (max-width: 430px) {
html,
body {
width: 100%;
}
.report {
width: 100%;
padding-left: 12px;
padding-right: 12px;
}
}
</style>
</head>
<body class="{{REPORT_MODE_CLASS}}">
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
</div>
<div class="hero-headline">
<b>{{HERO_HEADLINE}}</b>
<p>{{HERO_SUMMARY}}</p>
</div>
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
<div class="hero-inline-notes">
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
</div>
</header>
<section class="section {{TOPICS_EMPTY_CLASS}}">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
{{TOPICS_MORE_NOTE}}
</section>
<section class="section {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息</div>
{{IMPORTANT_MESSAGES}}
{{MESSAGES_MORE_NOTE}}
</section>
<section class="section {{ACTIONS_EMPTY_CLASS}}">
<div class="section-title">待办事项和未解决问题</div>
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
{{ACTIONS_MORE_NOTE}}
</section>
<section class="section {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">今日名场面</div>
{{QUOTE_BLOCKS}}
{{QUOTES_MORE_NOTE}}
</section>
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
<div class="section-title">今日群数据</div>
<div class="data-grid">
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
</div>
</div>
</section>
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
<div class="section-title">关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
{{KEYWORDS_MORE_NOTE}}
</section>
<section class="section {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
{{RESOURCES_MORE_NOTE}}
</section>
<section class="section {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
{{QA_MORE_NOTE}}
</section>
<section class="section {{STORYLINES_EMPTY_CLASS}}">
<div class="section-title">今日剧情时间线</div>
{{STORYLINE_CARDS}}
{{STORYLINES_MORE_NOTE}}
</section>
<section class="section {{REVERSALS_EMPTY_CLASS}}">
<div class="section-title">群聊反转现场</div>
{{REVERSAL_CARDS}}
{{REVERSALS_MORE_NOTE}}
</section>
<section class="section {{VISION_EMPTY_CLASS}}">
<div class="section-title">{{VISION_TITLE}}</div>
{{VISION_CARDS}}
</section>
<section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div>
{{GALLERY_CARDS}}
{{GALLERY_MORE_NOTE}}
</section>
<section class="section {{VOICE_EMPTY_CLASS}}">
<div class="section-title">语音之最</div>
{{VOICE_CARDS}}
{{VOICE_MORE_NOTE}}
</section>
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
<div class="section-title">语音时长榜</div>
<div class="card">{{VOICE_RANK_CARDS}}</div>
</section>
<section class="section {{BADGES_EMPTY_CLASS}}">
<div class="section-title">今日临时人设</div>
<div class="badge-grid">{{BADGE_CARDS}}</div>
{{BADGES_MORE_NOTE}}
</section>
<section class="section {{CHAINS_EMPTY_CLASS}}">
<div class="section-title">话题参与链路</div>
{{CHAIN_CARDS}}
{{CHAINS_MORE_NOTE}}
</section>
<footer class="footer">
数据来源:WechatExplorer · 微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
</main>
</body>
</html>
+17
View File
@@ -10,6 +10,23 @@ function setPlistValue(plistPath, key, value) {
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName === 'win32') {
const koffiNative = path.join(
context.appOutDir,
'resources',
'app.asar.unpacked',
'node_modules',
'@koromix',
'koffi-win32-x64',
'win32_x64',
'koffi.node'
)
if (!existsSync(koffiNative)) {
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
}
return
}
if (context.electronPlatformName !== 'darwin') return
const productName = context.packager.appInfo.productFilename
+65
View File
@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
function normalizePlatform(value) {
if (value === 'win32' || value === 'windows') return 'windows'
if (value === 'darwin' || value === 'macos') return 'darwin'
if (value === 'linux') return 'linux'
throw new Error(`Unsupported connector platform: ${value}`)
}
function normalizeArch(value) {
if (value === 'x64' || value === 'amd64') return 'amd64'
if (value === 'arm64') return 'arm64'
throw new Error(`Unsupported connector architecture: ${value}`)
}
function detectHostArch() {
if (process.platform !== 'darwin') return process.arch
try {
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
encoding: 'utf8'
}).trim()
return arm64Supported === '1' ? 'arm64' : process.arch
} catch {
return process.arch
}
}
function parseTargets() {
const platformArg = process.argv.indexOf('--platform')
const archArg = process.argv.indexOf('--arch')
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
return platforms.flatMap((platform) =>
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
)
}
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
}
for (const target of parseTargets()) {
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
const outputDir = path.join(outputRoot, directoryName)
const outputPath = path.join(
outputDir,
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
)
fs.rmSync(outputDir, { recursive: true, force: true })
fs.mkdirSync(outputDir, { recursive: true })
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
cwd: sourceDir,
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
stdio: 'inherit'
})
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
}
+409
View File
@@ -0,0 +1,409 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const templatePath = path.join(root, 'resources', 'mobile_daily_report.html')
const outputDir = path.join(os.tmpdir(), 'wechatexplorer-report-fixtures')
const escapeHtml = (value) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
const replacePlaceholder = (html, key, value) => html.replaceAll(`{{${key}}}`, value)
const avatarSvg = (label, color) =>
`data:image/svg+xml;base64,${Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
).toString('base64')}`
const localImagePath = '/Users/Wxw_/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/a969409112_d784/temp/RWTemp/2026-07/94ce24699a5a1d539c00a37ec8ace755.png'
const sampleImage = fs.existsSync(localImagePath)
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
: avatarSvg('图', '#dbeafe')
const avatars = {
阿宇: avatarSvg('宇', '#dcfce7'),
老周: avatarSvg('周', '#e0f2fe'),
小李: avatarSvg('李', '#fef3c7'),
'we water': avatarSvg('W', '#ede9fe'),
佩佩: avatarSvg('佩', '#fee2e2')
}
const heroNames = ['阿宇', '老周', '小李', 'we water']
const heroAvatars = heroNames
.map((name) => `<img src="${avatars[name]}" alt="${name}">`)
.join('')
const compactRequest = {
metadata: {
groupName: '技术交流',
reportDate: '2026-07-10',
dateRange: '2026-07-10 09:12-19:48',
messageCount: 382,
activeUsers: 47,
imageCount: 9,
voiceCount: 5,
stickerCount: 14,
mediaMessageCount: 28,
timeSpan: '11 h',
generatedAt: '2026-07-10 22:18',
recordNote: '基于示例数据生成的精简版日报',
footerNote: '精简版默认面向长图转发;无内容模块自动隐藏。',
heroParticipants: heroNames,
avatars,
reportMode: 'compact'
},
report: {
hero: {
headline: '接口排查和版本升级是今天主线',
summary: '白天主要围绕接口异常、升级节奏和上线安排展开,结论比争论更多,待跟进事项也比较集中。',
keyTakeaway: '大家确认本次异常更像缓存与配置问题,而不是服务端挂掉。',
pendingNote: '测试环境接口文档和回滚方案仍需补齐。',
statusLine: '今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决'
},
summaryStats: {
messageCount: 382,
activeUsers: 47,
topicCount: 4,
mediaCount: 28,
imageCount: 9,
voiceCount: 5,
stickerCount: 14,
conclusionCount: 3,
todoCount: 2,
unresolvedCount: 1
},
sectionMeta: {
hero: { enabled: true, importance: 1, confidence: 0.95, totalCount: 1, displayedCount: 1 },
topics: { enabled: true, importance: 0.98, confidence: 0.86, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
importantMessages: { enabled: true, importance: 0.95, confidence: 0.84, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
actions: { enabled: true, importance: 0.97, confidence: 0.81, totalCount: 5, displayedCount: 3, hiddenCount: 2 },
moments: { enabled: true, importance: 0.75, confidence: 0.75, totalCount: 3, displayedCount: 1, hiddenCount: 2 },
analytics: { enabled: true, importance: 0.8, confidence: 0.95, totalCount: 1, displayedCount: 1 },
keywords: { enabled: true, importance: 0.68, confidence: 0.92, totalCount: 16, displayedCount: 12, hiddenCount: 4 }
},
topics: [
{
title: 'GPT 接口异常排查',
timeRange: '09:20-11:05',
heat: '高',
summary: '上午先从接口超时和返回结构异常入手,几轮排查后,大家逐步把问题收敛到缓存与环境配置,而不是后端服务不可用。',
conclusions: [
{ text: '接口本身可用,异常更像本地缓存与环境变量冲突。' },
{ text: '先清缓存再复测,避免把旧响应误判成线上事故。' }
],
participants: ['阿宇', '老周', '小李'],
keywords: ['接口', '缓存', '环境变量'],
image: {
imageUrl: sampleImage,
note: '该图片引发 12 条回复。根据图片前后对话推断,这是一张帮助定位问题的截图。'
}
},
{
title: '版本升级节奏',
timeRange: '11:40-12:20',
heat: '中',
summary: '关于 React 版本是否立刻升级,讨论从“能不能升”转成“这周值不值得升”,最终倾向先补兼容性清单再动。',
conclusions: [{ text: '先列旧组件兼容清单,再决定升级窗口。' }],
participants: ['阿宇', 'we water', '佩佩'],
keywords: ['React', '升级', '兼容性']
},
{
title: '上线节奏与回滚准备',
timeRange: '15:10-16:05',
heat: '中',
summary: '下午讨论上线方案时,大家更关注回滚准备是否充分,最后把重点放在文档、监控和回滚路径补齐上。',
conclusions: [{ text: '上线前需要补一版简短回滚说明。' }],
participants: ['老周', '小李'],
keywords: ['上线', '回滚', '监控']
},
],
importantMessages: [
{ sender: '阿宇', time: '10:41', content: '先别回滚,接口能通。', note: '稳定了排查方向。' },
{ sender: '老周', time: '11:02', content: '像是缓存没清掉。', note: '把问题从服务端收敛到本地环境。' },
{ sender: '小李', time: '15:36', content: '上线前把回滚文档补一下。', note: '明确形成待办。' }
],
todos: [
{ task: '补测试环境接口文档', owner: '小李', topic: 'GPT 接口异常排查', note: '方便明天复测。' },
{ task: '整理回滚说明', owner: '老周', deadline: '今晚', topic: '上线节奏与回滚准备' }
],
unresolved: [
{ question: '缓存问题的根因是不是插件残留?', owner: '阿宇', status: '待跟进', lastDiscussedAt: '18:26', note: '目前只有推断,还没有最终证据。' }
],
quotes: [
{
messages: [
{ sender: '阿宇', content: '我以为接口炸了。' },
{ sender: '老周', content: '先别慌,先清缓存。' },
{ sender: '小李', content: '清完它居然真好了。' }
],
note: '从“要不要回滚”迅速切到“先做最小验证”,很像今天的群聊节奏。'
}
],
analytics: {
topSpeakers: [
{ name: '阿宇', count: 112 },
{ name: '老周', count: 78 },
{ name: '小李', count: 61 },
{ name: 'we water', count: 49 },
{ name: '佩佩', count: 33 }
],
activeTimeline: '09:00-09:59、10:00-10:59、15:00-15:59',
voiceLeaderboard: []
},
keywords: ['GPT', '接口', '缓存', '升级', '上线', '回滚', '监控', '兼容性', '截图', '文档', '测试环境', '复测'],
media: {
gallery: [],
voiceHighlights: [],
funBadges: []
},
resources: [],
qa: [],
storylines: [],
reversals: [],
participantChains: []
}
}
const fullRequest = JSON.parse(JSON.stringify(compactRequest))
fullRequest.metadata.recordNote = '基于示例数据生成的完整版日报'
fullRequest.metadata.reportMode = 'full'
fullRequest.report.sectionMeta = {
...fullRequest.report.sectionMeta,
resources: { enabled: true, importance: 0.58, confidence: 0.76, totalCount: 2, displayedCount: 2 },
qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 },
storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 },
reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 },
gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 },
voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 },
badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 },
chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
}
fullRequest.report.resources = [
{ title: '测试环境接口文档', description: '明天复测会直接用到的说明。', sender: '小李' },
{ title: '回滚说明草稿', description: '上线前确认回滚路径与负责人。', sender: '老周' }
]
fullRequest.report.qa = [
{ question: '今晚要不要升级 React', answer: '先不升级,先补兼容清单。', answerer: 'we water' },
{ question: '接口是不是服务端挂了?', answer: '不是,当前更像缓存与环境问题。', answerer: '老周' }
]
fullRequest.report.storylines = [
{
title: '接口异常排查线',
stages: [
{ time: '09:20', event: '阿宇提出接口异常。' },
{ time: '09:46', event: '老周建议先清缓存。' },
{ time: '10:41', event: '确认接口本身可通。' }
],
result: '初步定位为缓存与配置冲突。'
},
{
title: '版本升级讨论线',
stages: [
{ time: '11:40', event: '开始讨论是否本周升级。' },
{ time: '12:05', event: '补充兼容性与工期顾虑。' }
],
result: '今晚不升,先补兼容清单。'
}
]
fullRequest.report.reversals = [
{ topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
]
fullRequest.report.media = {
gallery: [
{
sender: '阿宇',
time: '09:52',
imageUrl: sampleImage,
note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
stats: '12 条后续消息 · 6 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
},
{
sender: '佩佩',
time: '17:14',
imageUrl: sampleImage,
note: '第二张图带起一轮轻松但有效的快速确认。',
stats: '5 条后续消息 · 3 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
}
],
voiceHighlights: [
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
],
funBadges: [
{ title: '高能输出王', owner: '阿宇', note: '今天一共发了 112 条消息。' },
{ title: '语音麦霸', owner: '老周', note: '语音总时长位列第一。' }
]
}
fullRequest.report.participantChains = [
{ topic: '接口异常排查', chain: ['阿宇 提出', '老周 收敛方向', '小李 验证', 'we water 定结论'], note: '比较典型的一条技术讨论链路。' }
]
fullRequest.report.analytics.voiceLeaderboard = [
{ sender: '老周', count: 3, durationSec: 97 },
{ sender: '阿宇', count: 2, durationSec: 54 }
]
async function renderRequest(request, targetBase) {
let html = await fs.readFile(templatePath, 'utf8')
const report = request.report
const metadata = request.metadata
const topicCards = report.topics
.map((topic) => {
const conclusions = (topic.conclusions || [])
.slice(0, 2)
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
.join('')
const image = topic.image?.imageUrl
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
: ''
return `<div class="card topic-card"><div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${topic.heat === '高' ? 'hot' : topic.heat === '低' ? 'blue' : ''}">${escapeHtml(topic.heat)}热</span></div><div class="topic-meta">${escapeHtml(topic.timeRange)}</div><p>${escapeHtml(topic.summary)}</p>${conclusions ? `<div class="topic-conclusions">${conclusions}</div>` : ''}${image}<div class="participants">${topic.participants.map((name) => `<span class="person-chip"><img src="${avatars[name] || avatarSvg(name[0], '#e5e7eb')}" alt=""><b>${escapeHtml(name)}</b></span>`).join('')}</div><div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div></div>`
})
.join('')
const importantMessages = report.importantMessages
.map((message) => `<div class="important-card"><img class="avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div><div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div></div>`)
.join('')
const todoCards = (report.todos || []).map((item) => `<div class="action-card todo-card"><b>${escapeHtml(item.task)}</b><div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
const unresolvedCards = (report.unresolved || []).map((item) => `<div class="action-card unresolved-card"><b>${escapeHtml(item.question)}</b><div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div><div class="action-note">${escapeHtml(item.note)}</div></div>`).join('')
const quoteBlocks = report.quotes.map((quote) => `<div class="chat-block">${quote.messages.map((message) => `<div class="chat-msg"><img class="chat-avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div><div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div></div></div>`).join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`).join('')
const rankItems = report.analytics.topSpeakers.map((speaker, index) => `<div class="rank"><img src="${avatars[speaker.name] || avatarSvg(speaker.name[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${speaker.count} 条</span></div>`).join('')
const cloudTags = report.keywords.map((word, index) => `<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`).join('')
const resourceItems = (report.resources || []).map((resource) => `<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`).join('')
const qaCards = (report.qa || []).map((item) => `<div class="qa-card"><b>Q${escapeHtml(item.question)}</b><div>A${escapeHtml(item.answer)}${item.answerer ? `${escapeHtml(item.answerer)}` : ''}</div></div>`).join('')
const storylineCards = (report.storylines || []).map((item) => `<div class="card storyline-card"><div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div><div class="storyline-steps">${item.stages.map((stage) => `<div class="storyline-step"><span>${escapeHtml(stage.time)}</span><b>${escapeHtml(stage.event)}</b></div>`).join('')}</div>${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}</div>`).join('')
const reversalCards = (report.reversals || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.topic)}</b><div>最初:${escapeHtml(item.initialView)}</div><div>后来:${escapeHtml(item.finalView)}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
const galleryCards = (report.media.gallery || []).map((item) => `<div class="gallery-card"><img class="gallery-image" src="${item.imageUrl}" alt=""><div class="gallery-body"><div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}<div class="important-text">${escapeHtml(item.note)}</div>${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}</div></div>`).join('')
const voiceCards = (report.media.voiceHighlights || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b><div>${escapeHtml(item.note)}</div></div>`).join('')
const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `<div class="rank"><img src="${avatars[item.sender] || avatarSvg(item.sender[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(item.sender)}</b><span>${item.count} 条 · ${item.durationSec} 秒</span></div>`).join('')
const badgeCards = (report.media.funBadges || []).map((item) => `<div class="badge-card"><span class="tag">${escapeHtml(item.title)}</span><b>${escapeHtml(item.owner)}</b><p>${escapeHtml(item.note)}</p></div>`).join('')
const chainCards = (report.participantChains || []).map((item) => `<div class="card chain-card"><div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div><div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}</div>`).join('')
const replaceMap = {
REPORT_TITLE: `${metadata.groupName}日报`,
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
GROUP_NAME: metadata.groupName,
DATE_RANGE: metadata.dateRange,
RECORD_NOTE: metadata.recordNote,
REPORT_MODE_LABEL: metadata.reportMode === 'full' ? '完整版' : '精简版',
HERO_HEADLINE: report.hero.headline,
HERO_SUMMARY: report.hero.summary,
HERO_STATUS_LINE: report.hero.statusLine || '',
HERO_STATUS_EMPTY_CLASS: report.hero.statusLine ? '' : 'empty-section',
HERO_TAKEAWAY: report.hero.keyTakeaway || '',
HERO_TAKEAWAY_EMPTY_CLASS: report.hero.keyTakeaway ? '' : 'empty-section',
HERO_PENDING: report.hero.pendingNote || '',
HERO_PENDING_EMPTY_CLASS: report.hero.pendingNote ? '' : 'empty-section',
HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(report.summaryStats.messageCount),
ACTIVE_USERS: String(report.summaryStats.activeUsers),
TOPIC_COUNT: String(report.summaryStats.topicCount),
MEDIA_COUNT: String(report.summaryStats.mediaCount),
TOPICS_EMPTY_CLASS: report.sectionMeta.topics?.enabled ? '' : 'empty-section',
TOPIC_CARDS: topicCards,
TOPICS_MORE_NOTE: report.sectionMeta.topics?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.topics.hiddenCount} 条内容,请在完整版中查看</div>` : '',
MESSAGES_EMPTY_CLASS: report.sectionMeta.importantMessages?.enabled ? '' : 'empty-section',
IMPORTANT_MESSAGES: importantMessages,
MESSAGES_MORE_NOTE: report.sectionMeta.importantMessages?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.importantMessages.hiddenCount} 条内容,请在完整版中查看</div>` : '',
ACTIONS_EMPTY_CLASS: report.sectionMeta.actions?.enabled ? '' : 'empty-section',
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
TODO_CARDS: todoCards,
UNRESOLVED_EMPTY_CLASS: report.unresolved.length ? '' : 'empty-section',
UNRESOLVED_CARDS: unresolvedCards,
ACTIONS_MORE_NOTE: report.sectionMeta.actions?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.actions.hiddenCount} 条内容,请在完整版中查看</div>` : '',
QUOTES_EMPTY_CLASS: report.sectionMeta.moments?.enabled ? '' : 'empty-section',
QUOTE_BLOCKS: quoteBlocks,
QUOTES_MORE_NOTE: report.sectionMeta.moments?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.moments.hiddenCount} 条内容,请在完整版中查看</div>` : '',
ANALYTICS_EMPTY_CLASS: '',
RANK_ITEMS: rankItems,
ACTIVITY_TIMELINE: report.analytics.activeTimeline,
CONCLUSION_COUNT: String(report.summaryStats.conclusionCount),
TODO_COUNT: String(report.summaryStats.todoCount),
UNRESOLVED_COUNT: String(report.summaryStats.unresolvedCount),
KEYWORDS_EMPTY_CLASS: report.sectionMeta.keywords?.enabled ? '' : 'empty-section',
CLOUD_TAGS: cloudTags,
KEYWORDS_MORE_NOTE: report.sectionMeta.keywords?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.keywords.hiddenCount} 个关键词,请在完整版中查看</div>` : '',
RESOURCES_EMPTY_CLASS: report.sectionMeta.resources?.enabled ? '' : 'empty-section',
RESOURCE_ITEMS: resourceItems,
RESOURCES_MORE_NOTE: '',
QA_EMPTY_CLASS: report.sectionMeta.qa?.enabled ? '' : 'empty-section',
QA_CARDS: qaCards,
QA_MORE_NOTE: '',
STORYLINES_EMPTY_CLASS: report.sectionMeta.storylines?.enabled ? '' : 'empty-section',
STORYLINE_CARDS: storylineCards,
STORYLINES_MORE_NOTE: '',
REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
REVERSAL_CARDS: reversalCards,
REVERSALS_MORE_NOTE: '',
GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section',
GALLERY_CARDS: galleryCards,
GALLERY_MORE_NOTE: '',
VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: '',
VOICE_RANK_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
VOICE_RANK_CARDS: voiceRankCards,
BADGES_EMPTY_CLASS: report.sectionMeta.badges?.enabled ? '' : 'empty-section',
BADGE_CARDS: badgeCards,
BADGES_MORE_NOTE: '',
CHAINS_EMPTY_CLASS: report.sectionMeta.chains?.enabled ? '' : 'empty-section',
CHAIN_CARDS: chainCards,
CHAINS_MORE_NOTE: '',
GENERATED_AT: metadata.generatedAt,
FOOTER_NOTE: metadata.footerNote
}
for (const [key, value] of Object.entries(replaceMap)) html = replacePlaceholder(html, key, value)
const htmlPath = path.join(outputDir, `${targetBase}.html`)
const pngPath = path.join(outputDir, `${targetBase}.png`)
await fs.ensureDir(outputDir)
await fs.writeFile(htmlPath, html, 'utf8')
const win = new BrowserWindow({
show: false,
width: 430,
height: 800,
frame: false,
backgroundColor: '#f3f5f7',
webPreferences: { sandbox: true }
})
await win.loadFile(htmlPath)
await win.webContents.executeJavaScript(`Promise.all([
document.fonts.ready,
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
}))
])`)
win.webContents.debugger.attach('1.3')
const metrics = await win.webContents.debugger.sendCommand('Page.getLayoutMetrics')
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
const height = Math.ceil(metrics.cssContentSize.height)
const screenshot = await win.webContents.debugger.sendCommand('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: true,
fromSurface: true,
clip: { x: 0, y: 0, width, height, scale: 1 }
})
await fs.writeFile(pngPath, Buffer.from(screenshot.data, 'base64'))
if (win.webContents.debugger.isAttached()) win.webContents.debugger.detach()
win.destroy()
return { htmlPath, pngPath, height }
}
app.whenReady().then(async () => {
const mode = process.argv.includes('--full') ? 'full' : 'compact'
const result =
mode === 'full'
? await renderRequest(fullRequest, 'full-fixture')
: await renderRequest(compactRequest, 'compact-fixture')
console.log(JSON.stringify({ mode, result, outputDir }, null, 2))
app.quit()
})
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fastclaw-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
# WechatExplorer WeChat Connector
This repository-local service provides the minimal WeChat bridge required by WechatExplorer:
- QR-code login with a single persisted credential
- account discovery
- inbound long polling and authenticated webhook delivery
- local HTTP health and send endpoints
- text and local/remote media sending
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
## Commands
```bash
go run . login --json
go run . accounts --json
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
```
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
## Attribution
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). WechatExplorer-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding WechatExplorer project.
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
)
// Server provides an HTTP API for sending messages.
type Server struct {
clients []*ilink.Client
addr string
}
// NewServer creates an API server.
func NewServer(clients []*ilink.Client, addr string) *Server {
if addr == "" {
addr = "127.0.0.1:18011"
}
return &Server{clients: clients, addr: addr}
}
// SendRequest is the JSON body for POST /api/send.
type SendRequest struct {
AccountID string `json:"account_id,omitempty"`
To string `json:"to"`
Text string `json:"text,omitempty"`
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
}
// Run starts the HTTP server. Blocks until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/send", s.handleSend)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
srv := &http.Server{Addr: s.addr, Handler: mux}
go func() {
<-ctx.Done()
srv.Shutdown(context.Background())
}()
log.Printf("[api] listening on %s", s.addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req SendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.To == "" {
http.Error(w, `"to" is required`, http.StatusBadRequest)
return
}
if req.Text == "" && req.MediaURL == "" {
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
return
}
if len(s.clients) == 0 {
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
return
}
client := s.clientForAccount(req.AccountID)
if client == nil {
http.Error(w, "requested account is not available", http.StatusNotFound)
return
}
ctx := r.Context()
// Send text if provided
if req.Text != "" {
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
log.Printf("[api] send text failed: %v", err)
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
// Extract and send any markdown images embedded in text
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
log.Printf("[api] send extracted image failed: %v", err)
} else {
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
}
}
}
// Send media if provided
if req.MediaURL != "" {
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
log.Printf("[api] send media failed: %v", err)
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) clientForAccount(accountID string) *ilink.Client {
if accountID == "" {
return s.clients[0]
}
for _, client := range s.clients {
if client.BotID() == accountID {
return client
}
}
return nil
}
@@ -0,0 +1,20 @@
package api
import (
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
if got := server.clientForAccount("bot-new"); got != newClient {
t.Fatal("clientForAccount did not select the requested account")
}
if got := server.clientForAccount("missing"); got != nil {
t.Fatal("clientForAccount should reject an unknown account")
}
}
+8
View File
@@ -0,0 +1,8 @@
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
go 1.23.0
require (
github.com/google/uuid v1.6.0
rsc.io/qr v0.2.0
)
+4
View File
@@ -0,0 +1,4 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+197
View File
@@ -0,0 +1,197 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
statusWait = "wait"
statusScanned = "scaned"
statusConfirmed = "confirmed"
statusExpired = "expired"
)
// FetchQRCode retrieves a new QR code for login.
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
c := NewUnauthenticatedClient()
var resp QRCodeResponse
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
return nil, fmt.Errorf("fetch QR code: %w", err)
}
return &resp, nil
}
// PollQRStatus polls for QR code scan status until confirmed or expired.
// It calls onStatus for each status change so the caller can display progress.
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
c := NewUnauthenticatedClient()
url := qrStatusURL + qrcode
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
var resp QRStatusResponse
err := c.doGet(pollCtx, url, &resp)
cancel()
if err != nil {
// Timeout is normal for long-poll, retry
if ctx.Err() != nil {
return nil, ctx.Err()
}
continue
}
if onStatus != nil {
onStatus(resp.Status)
}
switch resp.Status {
case statusConfirmed:
creds := &Credentials{
BotToken: resp.BotToken,
ILinkBotID: resp.ILinkBotID,
BaseURL: resp.BaseURL,
ILinkUserID: resp.ILinkUserID,
}
return creds, nil
case statusExpired:
return nil, fmt.Errorf("QR code expired")
case statusWait, statusScanned:
// Continue polling
default:
// Unknown status, continue
}
}
}
// AccountsDir returns the directory where account credentials are stored.
func AccountsDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil
}
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
func NormalizeAccountID(raw string) string {
s := raw
for _, ch := range []string{"@", ".", ":"} {
s = filepath.Clean(s)
s = replaceAll(s, ch, "-")
}
return s
}
func replaceAll(s, old, new string) string {
for {
i := indexOf(s, old)
if i < 0 {
return s
}
s = s[:i] + new + s[i+len(old):]
}
}
func indexOf(s, sub string) int {
for i := range s {
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// SaveCredentials saves the latest credentials and removes older accounts.
// The new credential is written first so a failed login never destroys the
// previously working credential.
func SaveCredentials(creds *Credentials) error {
dir, err := AccountsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create accounts dir: %w", err)
}
id := NormalizeAccountID(creds.ILinkBotID)
path := filepath.Join(dir, id+".json")
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("prune old credentials: %w", err)
}
keepPrefix := id + "."
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
continue
}
if filepath.Ext(entry.Name()) != ".json" {
continue
}
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
}
}
return nil
}
// LoadAllCredentials loads all saved account credentials.
func LoadAllCredentials() ([]*Credentials, error) {
dir, err := AccountsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read accounts dir: %w", err)
}
var result []*Credentials
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var creds Credentials
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
result = append(result, &creds)
}
}
return result, nil
}
// CredentialsPath returns the path for display purposes.
func CredentialsPath() (string, error) {
return AccountsDir()
}
@@ -0,0 +1,36 @@
package ilink
import (
"os"
"path/filepath"
"testing"
)
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
t.Setenv("HOME", t.TempDir())
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
if err := SaveCredentials(old); err != nil {
t.Fatal(err)
}
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if err := SaveCredentials(latest); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
t.Fatalf("old sync state still exists: %v", err)
}
}
+218
View File
@@ -0,0 +1,218 @@
package ilink
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
defaultBaseURL = "https://ilinkai.weixin.qq.com"
longPollTimeout = 35 * time.Second
sendTimeout = 15 * time.Second
)
// Client is an iLink HTTP API client.
type Client struct {
baseURL string
botToken string
botID string
httpClient *http.Client
wechatUIN string
}
// NewClient creates a new iLink API client.
func NewClient(creds *Credentials) *Client {
baseURL := creds.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
return &Client{
baseURL: baseURL,
botToken: creds.BotToken,
botID: creds.ILinkBotID,
httpClient: &http.Client{},
wechatUIN: generateWechatUIN(),
}
}
// NewUnauthenticatedClient creates a client without credentials for login flow.
func NewUnauthenticatedClient() *Client {
return &Client{
baseURL: defaultBaseURL,
httpClient: &http.Client{Timeout: 40 * time.Second},
wechatUIN: generateWechatUIN(),
}
}
// BotID returns the bot's user ID.
func (c *Client) BotID() string {
return c.botID
}
// GetUpdates performs a long-poll for new messages.
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
reqBody := GetUpdatesRequest{
GetUpdatesBuf: buf,
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
}
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
defer cancel()
var resp GetUpdatesResponse
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendMessage sends a message through iLink.
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp SendMessageResponse
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetConfig fetches bot config for a user (includes typing_ticket).
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := GetConfigRequest{
ILinkUserID: userID,
ContextToken: contextToken,
BaseInfo: BaseInfo{},
}
var resp GetConfigResponse
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendTyping sends a typing indicator to a user.
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := SendTypingRequest{
ILinkUserID: userID,
TypingTicket: typingTicket,
Status: status,
BaseInfo: BaseInfo{},
}
var resp SendTypingResponse
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
return err
}
if resp.Ret != 0 {
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
return nil
}
// GetUploadURL gets a pre-signed CDN upload URL for media files.
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp GetUploadURLResponse
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// BaseURL returns the base URL for CDN operations.
func (c *Client) BaseURL() string {
return c.baseURL
}
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("Authorization", "Bearer "+c.botToken)
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
}
func generateWechatUIN() string {
var n uint32
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
s := fmt.Sprintf("%d", n)
return base64.StdEncoding.EncodeToString([]byte(s))
}
+181
View File
@@ -0,0 +1,181 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
const (
maxConsecutiveFailures = 5
initialBackoff = 3 * time.Second
maxBackoff = 60 * time.Second
sessionExpiredBackoff = 5 * time.Second
errCodeSessionExpired = -14
)
// MessageHandler is called for each received message.
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
// Monitor manages the long-poll loop for receiving messages.
type Monitor struct {
client *Client
handler MessageHandler
getUpdatesBuf string
bufPath string
failures int
lastActivity time.Time
}
// NewMonitor creates a new long-poll monitor.
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
accountID := NormalizeAccountID(client.BotID())
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
m := &Monitor{
client: client,
handler: handler,
bufPath: bufPath,
lastActivity: time.Now(),
}
m.loadBuf()
return m, nil
}
// Run starts the long-poll loop. It blocks until ctx is cancelled.
// Automatically recovers from errors with exponential backoff.
func (m *Monitor) Run(ctx context.Context) error {
log.Println("[monitor] starting long-poll loop")
for {
select {
case <-ctx.Done():
log.Println("[monitor] shutting down")
return ctx.Err()
default:
}
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
m.failures++
backoff := m.calcBackoff()
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
m.failures, maxConsecutiveFailures, backoff, err)
if m.failures == maxConsecutiveFailures {
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from WechatExplorer if this persists.", maxConsecutiveFailures)
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Reset failure counter on any successful response
m.failures = 0
m.lastActivity = time.Now()
// Session expired — reset sync buf and reconnect silently
if resp.ErrCode == errCodeSessionExpired {
if m.getUpdatesBuf != "" {
log.Printf("[monitor] session expired, resetting sync buf")
m.getUpdatesBuf = ""
m.saveBuf()
} else {
// Sync buf already empty but still getting session expired:
// the bot token itself has expired. The user needs to re-login.
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from WechatExplorer.")
}
select {
case <-time.After(sessionExpiredBackoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Other server errors
if resp.Ret != 0 && resp.ErrCode != 0 {
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
continue
}
// Update buf for next poll
if resp.GetUpdatesBuf != "" {
m.getUpdatesBuf = resp.GetUpdatesBuf
m.saveBuf()
}
// Process messages concurrently — don't block the poll loop
for _, msg := range resp.Msgs {
go m.handler(ctx, m.client, msg)
}
}
}
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
func (m *Monitor) calcBackoff() time.Duration {
d := initialBackoff
for i := 1; i < m.failures; i++ {
d *= 2
if d > maxBackoff {
return maxBackoff
}
}
return d
}
type syncData struct {
GetUpdatesBuf string `json:"get_updates_buf"`
}
func (m *Monitor) loadBuf() {
data, err := os.ReadFile(m.bufPath)
if err != nil {
return
}
var s syncData
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
m.getUpdatesBuf = s.GetUpdatesBuf
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
}
}
func (m *Monitor) saveBuf() {
dir := filepath.Dir(m.bufPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("[monitor] failed to create buf dir: %v", err)
return
}
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
log.Printf("[monitor] failed to save buf: %v", err)
}
}
// FormatMessageSummary returns a short description of a message for logging.
func FormatMessageSummary(msg WeixinMessage) string {
text := ""
for _, item := range msg.ItemList {
if item.Type == ItemTypeText && item.TextItem != nil {
text = item.TextItem.Text
break
}
}
if len(text) > 50 {
text = text[:50] + "..."
}
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
}
+219
View File
@@ -0,0 +1,219 @@
package ilink
// Message types
const (
MessageTypeNone = 0
MessageTypeUser = 1
MessageTypeBot = 2
)
// Message states
const (
MessageStateNew = 0
MessageStateGenerating = 1
MessageStateFinish = 2
)
// Item types
const (
ItemTypeNone = 0
ItemTypeText = 1
ItemTypeImage = 2
ItemTypeVoice = 3
ItemTypeFile = 4
ItemTypeVideo = 5
)
// QRCodeResponse is the response from get_bot_qrcode.
type QRCodeResponse struct {
QRCode string `json:"qrcode"`
QRCodeImgContent string `json:"qrcode_img_content"`
}
// QRStatusResponse is the response from get_qrcode_status.
type QRStatusResponse struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// Credentials stores login session data.
type Credentials struct {
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// BaseInfo is included in request bodies.
type BaseInfo struct {
ChannelVersion string `json:"channel_version,omitempty"`
}
// GetUpdatesRequest is the body for getupdates.
type GetUpdatesRequest struct {
GetUpdatesBuf string `json:"get_updates_buf"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUpdatesResponse is the response from getupdates.
type GetUpdatesResponse struct {
Ret int `json:"ret"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Msgs []WeixinMessage `json:"msgs"`
GetUpdatesBuf string `json:"get_updates_buf"`
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
}
// WeixinMessage represents a message from WeChat.
type WeixinMessage struct {
Seq int `json:"seq,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// MessageItem is a single item in a message.
type MessageItem struct {
Type int `json:"type"`
TextItem *TextItem `json:"text_item,omitempty"`
ImageItem *ImageItem `json:"image_item,omitempty"`
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
VideoItem *VideoItem `json:"video_item,omitempty"`
FileItem *FileItem `json:"file_item,omitempty"`
}
// CDN media type constants.
const (
CDNMediaTypeImage = 1
CDNMediaTypeVideo = 2
CDNMediaTypeFile = 3
)
// GetUploadURLRequest is the body for getuploadurl.
type GetUploadURLRequest struct {
FileKey string `json:"filekey"`
MediaType int `json:"media_type"`
ToUserID string `json:"to_user_id"`
RawSize int `json:"rawsize"`
RawFileMD5 string `json:"rawfilemd5"`
FileSize int `json:"filesize"`
NoNeedThumb bool `json:"no_need_thumb"`
AESKey string `json:"aeskey"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUploadURLResponse is the response from getuploadurl.
type GetUploadURLResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
UploadParam string `json:"upload_param"`
UploadFullURL string `json:"upload_full_url,omitempty"`
}
// TextItem holds text content.
type TextItem struct {
Text string `json:"text"`
}
// MediaInfo holds CDN media reference for uploaded files.
type MediaInfo struct {
EncryptQueryParam string `json:"encrypt_query_param"`
AESKey string `json:"aes_key"` // base64-encoded
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
}
// VoiceItem holds voice content.
type VoiceItem struct {
Media *MediaInfo `json:"media,omitempty"`
VoiceSize int `json:"voice_size,omitempty"`
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
BitsPerSample int `json:"bits_per_sample,omitempty"`
SampleRate int `json:"sample_rate,omitempty"` // Hz
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
}
// ImageItem holds image content.
type ImageItem struct {
URL string `json:"url,omitempty"`
Media *MediaInfo `json:"media,omitempty"`
MidSize int `json:"mid_size,omitempty"` // ciphertext size
}
// VideoItem holds video content.
type VideoItem struct {
Media *MediaInfo `json:"media,omitempty"`
VideoSize int `json:"video_size,omitempty"`
}
// FileItem holds file content.
type FileItem struct {
Media *MediaInfo `json:"media,omitempty"`
FileName string `json:"file_name,omitempty"`
Len string `json:"len,omitempty"` // plaintext size as string
}
// SendMessageRequest is the body for sendmessage.
type SendMessageRequest struct {
Msg SendMsg `json:"msg"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendMsg is the message payload for sending.
type SendMsg struct {
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
ClientID string `json:"client_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// SendMessageResponse is the response from sendmessage.
type SendMessageResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
// Typing status constants.
const (
TypingStatusTyping = 1
TypingStatusCancel = 2
)
// GetConfigRequest is the body for getconfig.
type GetConfigRequest struct {
ILinkUserID string `json:"ilink_user_id"`
ContextToken string `json:"context_token,omitempty"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetConfigResponse is the response from getconfig.
type GetConfigResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
TypingTicket string `json:"typing_ticket,omitempty"`
}
// SendTypingRequest is the body for sendtyping.
type SendTypingRequest struct {
ILinkUserID string `json:"ilink_user_id"`
TypingTicket string `json:"typing_ticket"`
Status int `json:"status"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendTypingResponse is the response from sendtyping.
type SendTypingResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
"rsc.io/qr"
)
type loginEvent struct {
Status string `json:"status"`
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
AccountID string `json:"account_id,omitempty"`
WeChatUserID string `json:"wechat_user_id,omitempty"`
}
type accountSummary struct {
AccountID string `json:"account_id"`
WeChatUserID string `json:"wechat_user_id"`
}
func main() {
if len(os.Args) < 2 {
fatal(errors.New("expected one of: login, accounts, start"))
}
var err error
switch os.Args[1] {
case "login":
err = runLogin(os.Args[2:])
case "accounts":
err = runAccounts(os.Args[2:])
case "start":
err = runStart(os.Args[2:])
default:
err = fmt.Errorf("unknown command %q", os.Args[1])
}
if err != nil {
fatal(err)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
}
func runLogin(args []string) error {
flags := flag.NewFlagSet("login", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
if err := flags.Parse(args); err != nil {
return err
}
ctx, cancel := signalContext()
defer cancel()
creds, err := login(ctx, *jsonOutput)
if err != nil {
return err
}
if !*jsonOutput {
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
}
return nil
}
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
qrResponse, err := ilink.FetchQRCode(ctx)
if err != nil {
return nil, err
}
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
if err != nil {
return nil, fmt.Errorf("encode QR image: %w", err)
}
emit := func(event loginEvent) {
if jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(event)
}
}
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
lastStatus := ""
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
if status != lastStatus {
lastStatus = status
emit(loginEvent{Status: status})
}
})
if err != nil {
return nil, err
}
if err := ilink.SaveCredentials(creds); err != nil {
return nil, fmt.Errorf("save credentials: %w", err)
}
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
return creds, nil
}
func runAccounts(args []string) error {
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "print JSON")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
items := make([]accountSummary, 0, len(accounts))
for _, account := range accounts {
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
}
if *jsonOutput {
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
}
for _, item := range items {
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
}
return nil
}
func runStart(args []string) error {
flags := flag.NewFlagSet("start", flag.ContinueOnError)
_ = flags.Bool("foreground", false, "kept for host compatibility")
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
accountID := flags.String("account-id", "", "account to start")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
if len(accounts) == 0 {
return errors.New("no connected WeChat account; scan a QR code first")
}
selected := accounts[len(accounts)-1]
if *accountID != "" {
selected = nil
for _, account := range accounts {
if account.ILinkBotID == *accountID {
selected = account
break
}
}
if selected == nil {
return fmt.Errorf("account %q not found", *accountID)
}
}
ctx, cancel := signalContext()
defer cancel()
client := ilink.NewClient(selected)
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
if webhookURL != "" {
webhook.Dispatch(messageContext, source, message)
}
})
if err != nil {
return err
}
var wait sync.WaitGroup
wait.Add(2)
go func() {
defer wait.Done()
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[api] stopped: %v", err)
cancel()
}
}()
go func() {
defer wait.Done()
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[monitor] stopped: %v", err)
cancel()
}
}()
wait.Wait()
return nil
}
+232
View File
@@ -0,0 +1,232 @@
package messaging
import (
"bytes"
"context"
"crypto/aes"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
// UploadedFile holds the result of a CDN upload.
type UploadedFile struct {
DownloadParam string // encrypted query param for download
AESKeyHex string // hex-encoded AES key
FileSize int // plaintext size
CipherSize int // ciphertext size
}
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
// Generate random filekey and AES key
filekey := make([]byte, 16)
aeskey := make([]byte, 16)
if _, err := rand.Read(filekey); err != nil {
return nil, fmt.Errorf("generate filekey: %w", err)
}
if _, err := rand.Read(aeskey); err != nil {
return nil, fmt.Errorf("generate aeskey: %w", err)
}
filekeyHex := hex.EncodeToString(filekey)
aeskeyHex := hex.EncodeToString(aeskey)
// Calculate MD5 of plaintext
hash := md5.Sum(data)
rawMD5 := hex.EncodeToString(hash[:])
// Calculate ciphertext size (PKCS7 padding)
cipherSize := aesECBPaddedSize(len(data))
// Get upload URL from iLink API
uploadReq := &ilink.GetUploadURLRequest{
FileKey: filekeyHex,
MediaType: mediaType,
ToUserID: toUserID,
RawSize: len(data),
RawFileMD5: rawMD5,
FileSize: cipherSize,
NoNeedThumb: true,
AESKey: aeskeyHex,
BaseInfo: ilink.BaseInfo{},
}
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
if err != nil {
return nil, fmt.Errorf("get upload URL: %w", err)
}
if uploadResp.Ret != 0 {
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
}
// Encrypt data with AES-128-ECB
encrypted, err := encryptAESECB(data, aeskey)
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
if cdnURL == "" {
if uploadResp.UploadParam == "" {
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
}
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
}
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
if err != nil {
return nil, fmt.Errorf("CDN upload: %w", err)
}
return &UploadedFile{
DownloadParam: downloadParam,
AESKeyHex: aeskeyHex,
FileSize: len(data),
CipherSize: cipherSize,
}, nil
}
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
func AESKeyToBase64(hexKey string) string {
return base64.StdEncoding.EncodeToString([]byte(hexKey))
}
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
// Decode AES key: base64 -> hex string -> raw bytes
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
if err != nil {
return nil, fmt.Errorf("decode AES key base64: %w", err)
}
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
if err != nil {
return nil, fmt.Errorf("decode AES key hex: %w", err)
}
// Download encrypted data from CDN
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
cdnBaseURL, url.QueryEscape(encryptQueryParam))
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download from CDN: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
}
encrypted, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read CDN response: %w", err)
}
// Decrypt AES-128-ECB
return decryptAESECB(encrypted, aesKey)
}
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
// Remove PKCS7 padding
if len(plaintext) == 0 {
return plaintext, nil
}
padLen := int(plaintext[len(plaintext)-1])
if padLen > aes.BlockSize || padLen == 0 {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
return plaintext[:len(plaintext)-padLen], nil
}
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/octet-stream")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
}
downloadParam := resp.Header.Get("X-Encrypted-Param")
if downloadParam == "" {
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
}
return downloadParam, nil
}
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7 padding
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
padded := make([]byte, len(plaintext)+padLen)
copy(padded, plaintext)
for i := len(plaintext); i < len(padded); i++ {
padded[i] = byte(padLen)
}
// ECB mode: encrypt each block independently
encrypted := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
}
return encrypted, nil
}
func aesECBPaddedSize(plaintextSize int) int {
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
}
@@ -0,0 +1,121 @@
package messaging
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const (
webhookAttempts = 3
webhookTimeout = 5 * time.Second
)
type InboundWebhook struct {
url string
token string
client *http.Client
}
type inboundWebhookPayload struct {
AccountID string `json:"account_id"`
FromUserID string `json:"from_user_id"`
MessageID int64 `json:"message_id"`
MessageType int `json:"message_type"`
Items []inboundWebhookItem `json:"items"`
ReceivedAt time.Time `json:"received_at"`
}
type inboundWebhookItem struct {
Type int `json:"type"`
Text string `json:"text,omitempty"`
}
func NewInboundWebhook(url, token string) *InboundWebhook {
return &InboundWebhook{
url: strings.TrimSpace(url),
token: token,
client: &http.Client{Timeout: webhookTimeout},
}
}
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
payload := normalizeInboundMessage(client.BotID(), msg)
go func() {
if err := w.deliver(ctx, payload); err != nil {
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
}
}()
}
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode payload: %w", err)
}
var lastErr error
for attempt := 1; attempt <= webhookAttempts; attempt++ {
if attempt > 1 {
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
if reqErr != nil {
return fmt.Errorf("create request: %w", reqErr)
}
req.Header.Set("Content-Type", "application/json")
if w.token != "" {
req.Header.Set("Authorization", "Bearer "+w.token)
}
resp, doErr := w.client.Do(req)
if doErr != nil {
lastErr = doErr
continue
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
break
}
}
return lastErr
}
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
for _, item := range msg.ItemList {
normalized := inboundWebhookItem{Type: item.Type}
if item.TextItem != nil {
normalized.Text = item.TextItem.Text
} else if item.VoiceItem != nil {
normalized.Text = item.VoiceItem.Text
}
items = append(items, normalized)
}
return inboundWebhookPayload{
AccountID: accountID,
FromUserID: msg.FromUserID,
MessageID: msg.MessageID,
MessageType: msg.MessageType,
Items: items,
ReceivedAt: time.Now().UTC(),
}
}
@@ -0,0 +1,75 @@
package messaging
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
var got inboundWebhookPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "secret")
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
}))
if err != nil {
t.Fatalf("deliver: %v", err)
}
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
t.Fatalf("payload = %#v", got)
}
}
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < 3 {
http.Error(w, "temporary", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
t.Fatalf("deliver: %v", err)
}
if calls.Load() != 3 {
t.Fatalf("calls = %d, want 3", calls.Load())
}
}
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
t.Fatal("deliver error = nil")
}
if calls.Load() != 1 {
t.Fatalf("calls = %d, want 1", calls.Load())
}
}
@@ -0,0 +1,103 @@
package messaging
import (
"regexp"
"strings"
)
var (
// Code blocks: strip fences, keep code content
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
// Inline code: strip backticks, keep content
reInlineCode = regexp.MustCompile("`([^`]+)`")
// Images: remove entirely
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
// Links: keep display text only
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
// Table separator rows: remove
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
// Table rows: convert pipe-delimited to space-delimited
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
// Headers: remove # prefix
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
// Bold: **text** or __text__
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
// Italic: *text* or _text_
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
// Strikethrough: ~~text~~
reStrike = regexp.MustCompile(`~~(.+?)~~`)
// Blockquote: > prefix
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
// Horizontal rule
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
// Unordered list markers: -, *, +
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
)
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
func MarkdownToPlainText(text string) string {
result := text
// Code blocks: strip fences, keep code content
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
parts := reCodeBlock.FindStringSubmatch(match)
if len(parts) > 1 {
return strings.TrimSpace(parts[1])
}
return match
})
// Images: remove entirely
result = reImage.ReplaceAllString(result, "")
// Links: keep display text only
result = reLink.ReplaceAllString(result, "$1")
// Table separator rows: remove
result = reTableSep.ReplaceAllString(result, "")
// Table rows: pipe-delimited to space-delimited
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
parts := reTableRow.FindStringSubmatch(match)
if len(parts) > 1 {
cells := strings.Split(parts[1], "|")
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
return strings.Join(cells, " ")
}
return match
})
// Headers: remove # prefix
result = reHeader.ReplaceAllString(result, "")
// Bold
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
parts := reBold.FindStringSubmatch(match)
if parts[1] != "" {
return parts[1]
}
return parts[2]
})
// Strikethrough
result = reStrike.ReplaceAllString(result, "$1")
// Blockquote
result = reBlockquote.ReplaceAllString(result, "")
// Horizontal rule -> empty line
result = reHR.ReplaceAllString(result, "")
// Unordered list: replace markers with "• "
result = reUL.ReplaceAllString(result, "${1}• ")
// Inline code: strip backticks (do after code blocks)
result = reInlineCode.ReplaceAllString(result, "$1")
// Clean up excessive blank lines
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
return strings.TrimSpace(result)
}
@@ -0,0 +1,221 @@
package messaging
import (
"context"
"fmt"
"io"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
// reMarkdownImage matches markdown image syntax: ![alt](url)
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
// ExtractImageURLs extracts image URLs from markdown text.
func ExtractImageURLs(text string) []string {
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
var urls []string
for _, m := range matches {
url := strings.TrimSpace(m[1])
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
urls = append(urls, url)
}
}
return urls
}
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
// Check if it's a local file
if _, err := os.Stat(mediaURL); err == nil {
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
}
// Must be a valid HTTP URL to download
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
}
data, contentType, err := downloadFile(ctx, mediaURL)
if err != nil {
return fmt.Errorf("download %s: %w", mediaURL, err)
}
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
}
// SendMediaFromPath reads a local file and sends it as a media message.
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
}
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
if fileName == "" {
fileName = "file"
}
cdnMediaType, itemType := classifyMedia(contentType, source)
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
if err != nil {
return fmt.Errorf("upload to CDN: %w", err)
}
media := &ilink.MediaInfo{
EncryptQueryParam: uploaded.DownloadParam,
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
EncryptType: 1,
}
var item ilink.MessageItem
switch itemType {
case ilink.ItemTypeImage:
item = ilink.MessageItem{
Type: ilink.ItemTypeImage,
ImageItem: &ilink.ImageItem{
Media: media,
MidSize: uploaded.CipherSize,
},
}
case ilink.ItemTypeVideo:
item = ilink.MessageItem{
Type: ilink.ItemTypeVideo,
VideoItem: &ilink.VideoItem{
Media: media,
VideoSize: uploaded.CipherSize,
},
}
default:
item = ilink.MessageItem{
Type: ilink.ItemTypeFile,
FileItem: &ilink.FileItem{
Media: media,
FileName: fileName,
Len: fmt.Sprintf("%d", uploaded.FileSize),
},
}
}
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: NewClientID(),
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{item},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send media message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
return nil
}
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = inferContentType(url)
}
return data, contentType, nil
}
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
ct := strings.ToLower(contentType)
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
}
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
}
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
}
func isImageExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
return true
}
return false
}
func isVideoExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
return true
}
return false
}
func inferContentType(url string) string {
ext := filepath.Ext(stripQuery(url))
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
func filenameFromURL(rawURL string) string {
u := stripQuery(rawURL)
name := filepath.Base(u)
if name == "" || name == "." || name == "/" {
return "file"
}
return name
}
func stripQuery(rawURL string) string {
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
return rawURL[:i]
}
return rawURL
}
@@ -0,0 +1,73 @@
package messaging
import "testing"
func TestExtractImageURLs(t *testing.T) {
text := "check ![img](https://example.com/a.png) and ![](https://example.com/b.jpg)"
urls := ExtractImageURLs(text)
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %d", len(urls))
}
if urls[0] != "https://example.com/a.png" {
t.Errorf("urls[0] = %q", urls[0])
}
if urls[1] != "https://example.com/b.jpg" {
t.Errorf("urls[1] = %q", urls[1])
}
}
func TestExtractImageURLs_NoImages(t *testing.T) {
urls := ExtractImageURLs("just plain text")
if len(urls) != 0 {
t.Errorf("expected 0 urls, got %d", len(urls))
}
}
func TestExtractImageURLs_RelativeURL(t *testing.T) {
text := "![img](./local.png)"
urls := ExtractImageURLs(text)
if len(urls) != 0 {
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
}
}
func TestFilenameFromURL(t *testing.T) {
tests := []struct {
url string
want string
}{
{"https://example.com/photo.png", "photo.png"},
{"https://example.com/path/to/report.pdf", "report.pdf"},
{"https://example.com/file", "file"},
}
for _, tt := range tests {
got := filenameFromURL(tt.url)
if got != tt.want {
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
}
}
}
func TestFilenameFromURL_WithQuery(t *testing.T) {
got := filenameFromURL("https://example.com/photo.png?token=abc")
if got != "photo.png" {
t.Errorf("got %q, want %q", got, "photo.png")
}
}
func TestStripQuery(t *testing.T) {
tests := []struct {
input string
want string
}{
{"https://example.com/a?b=c", "https://example.com/a"},
{"https://example.com/a", "https://example.com/a"},
{"https://example.com/?x=1&y=2", "https://example.com/"},
}
for _, tt := range tests {
got := stripQuery(tt.input)
if got != tt.want {
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
@@ -0,0 +1,86 @@
package messaging
import (
"context"
"fmt"
"log"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/google/uuid"
)
// NewClientID generates a new unique client ID for message correlation.
func NewClientID() string {
return uuid.New().String()
}
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
// It first fetches a typing_ticket via getconfig, then sends the typing status.
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
// Get typing ticket
configResp, err := client.GetConfig(ctx, userID, contextToken)
if err != nil {
return fmt.Errorf("get config for typing: %w", err)
}
if configResp.TypingTicket == "" {
return fmt.Errorf("no typing_ticket returned from getconfig")
}
// Send typing
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
return fmt.Errorf("send typing: %w", err)
}
log.Printf("[sender] sent typing indicator to %s", userID)
return nil
}
// SendTextReply sends a text reply to a user through the iLink API.
// If clientID is empty, a new one is generated.
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
if clientID == "" {
clientID = NewClientID()
}
// Convert markdown to plain text for WeChat display
plainText := MarkdownToPlainText(text)
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: clientID,
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{
{
Type: ilink.ItemTypeText,
TextItem: &ilink.TextItem{
Text: plainText,
},
},
},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
return nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+85
View File
@@ -0,0 +1,85 @@
import { app, shell } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import type { AppLogEntry } from '../shared/app-log'
import { isPackagedRuntime } from './runtime-mode'
const MAX_LOG_BYTES = 5 * 1024 * 1024
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
const sanitize = (value: unknown, depth = 0): unknown => {
if (depth > 4) return '[depth-limited]'
if (typeof value === 'string') {
return value
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
.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))
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
REDACTED_KEY.test(key) ? '***' : sanitize(item, depth + 1)
])
)
}
return value
}
export class AppLogger {
private get logDir(): string {
return app.getPath('logs')
}
get logPath(): string {
return path.join(this.logDir, 'wechatexplorer.log')
}
private rotateIfNeeded(): void {
try {
if (!fs.existsSync(this.logPath) || fs.statSync(this.logPath).size < MAX_LOG_BYTES) return
const previous = `${this.logPath}.1`
if (fs.existsSync(previous)) fs.removeSync(previous)
fs.moveSync(this.logPath, previous)
} catch {
// Logging must never interrupt the application.
}
}
write(entry: AppLogEntry): void {
try {
fs.ensureDirSync(this.logDir)
this.rotateIfNeeded()
const record = {
timestamp: new Date().toISOString(),
mode: isPackagedRuntime() ? 'packaged' : 'development',
level: entry.level,
scope: String(entry.scope || 'app').slice(0, 80),
message: String(sanitize(entry.message || '')).slice(0, 500),
details: sanitize(entry.details || {})
}
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
if (!isPackagedRuntime()) {
const method =
entry.level === 'error'
? console.error
: entry.level === 'warn'
? console.warn
: console.log
method(`[${record.scope}] ${record.message}`, record.details)
}
} catch {
// Logging must never interrupt the application.
}
}
reveal(): void {
fs.ensureDirSync(this.logDir)
if (!fs.existsSync(this.logPath)) fs.writeFileSync(this.logPath, '', 'utf8')
shell.showItemInFolder(this.logPath)
}
}
export const appLogger = new AppLogger()
+64 -16
View File
@@ -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) }
+101
View File
@@ -0,0 +1,101 @@
// src/main/db/image-insights-store.ts
// 持久化 ImageInsight 到 JSON 文件(userData/image-insights.json)
// 跟项目现有风格一致(ai-provider-service 用 ai-providers.json)
import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import type { ImageInsight } from '../../shared/image-insight'
interface ImageInsightsFile {
version: 1
/** imageHash -> ImageInsight 索引(缓存查询 O(1)) */
byHash: Record<string, ImageInsight>
/** messageId -> imageHash 反向索引(防止同一 message 重复入库) */
byMessageId: Record<string, string>
}
const EMPTY_FILE: ImageInsightsFile = {
version: 1,
byHash: {},
byMessageId: {}
}
class ImageInsightsStore {
private cache: ImageInsightsFile | null = null
private get filePath(): string {
return path.join(app.getPath('userData'), 'image-insights.json')
}
private ensureLoaded(): ImageInsightsFile {
if (this.cache) return this.cache
try {
if (fs.existsSync(this.filePath)) {
const raw = fs.readJsonSync(this.filePath) as Partial<ImageInsightsFile>
this.cache = {
version: 1,
byHash: raw.byHash || {},
byMessageId: raw.byMessageId || {}
}
return this.cache
}
} catch (error) {
console.warn('[ImageInsightsStore] failed to load, fallback to empty:', error)
}
this.cache = { ...EMPTY_FILE }
return this.cache
}
private persist(): void {
if (!this.cache) return
try {
fs.ensureDirSync(path.dirname(this.filePath))
fs.writeJsonSync(this.filePath, this.cache, { spaces: 2 })
} catch (error) {
console.error('[ImageInsightsStore] failed to persist:', error)
}
}
/** 通过 imageHash 查询缓存 */
getByHash(imageHash: string): ImageInsight | null {
return this.ensureLoaded().byHash[imageHash] || null
}
/** 列出某会话的所有 insights(按时间倒序) */
listBySession(sessionId: string, limit?: number): ImageInsight[] {
const data = this.ensureLoaded()
const items = Object.values(data.byHash)
.filter((it) => it.sessionId === sessionId)
.sort((a, b) => b.sentAt - a.sentAt)
return typeof limit === 'number' ? items.slice(0, limit) : items
}
/**
* Insight
* - imageHash 已存在:更新 description/ocrText/tags/category/importance/provider/model/updatedAt( createdAt)
* - hash:插入
* byMessageId
*/
upsert(insight: ImageInsight): void {
const data = this.ensureLoaded()
const existing = data.byHash[insight.imageHash]
const now = Date.now()
if (existing) {
data.byHash[insight.imageHash] = {
...existing,
...insight,
id: existing.id, // 保留 id
createdAt: existing.createdAt, // 保留首次分析时间
updatedAt: now
}
} else {
data.byHash[insight.imageHash] = { ...insight, createdAt: now, updatedAt: now }
data.byMessageId[insight.messageId] = insight.imageHash
}
this.persist()
}
}
export const imageInsightsStore = new ImageInsightsStore()
+44
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c
)
export function renderExportPage(name: string, messages: Message[]): string {
const body = messages
.map((m) => {
const avatar = m.img
? `<img src="${safe(m.img)}" alt="">`
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
const audio = m.voiceDataUrl
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${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>`
}
+406
View File
@@ -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)
}
+295 -34
View File
@@ -6,11 +6,29 @@ import {
GroupReportExportRequest,
GroupReportExportResult,
GroupReportMetadata,
ReportHeat
ReportHeat,
ReportSectionMeta
} from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
import { imageInsightService } from './services/image-insight-service'
const TEMPLATE_NAME = 'mobile_daily_report.html'
const TEMPLATE_FILES: Record<string, string> = {
v1: 'mobile_daily_report_v1.html',
v2: 'mobile_daily_report_v2.html'
}
const DEFAULT_TEMPLATE = TEMPLATE_FILES.v1
const templatePath = (templateId?: string): string => {
const name = TEMPLATE_FILES[templateId || ''] || DEFAULT_TEMPLATE
const candidates = [
path.join(process.resourcesPath, 'resources', name),
path.join(app.getAppPath(), 'resources', name),
path.join(process.cwd(), 'resources', name)
]
const found = candidates.find((candidate) => fs.existsSync(candidate))
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
return found
}
const escapeHtml = (value: unknown): string =>
String(value ?? '')
@@ -75,17 +93,6 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
}
}
const templatePath = (): string => {
const candidates = [
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
]
const found = candidates.find((candidate) => fs.existsSync(candidate))
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
return found
}
/**
* , metadata.avatars
* - talker ()
@@ -138,6 +145,26 @@ const heatClass = (heat: ReportHeat): string => {
const replacePlaceholder = (html: string, key: string, value: string): string =>
html.replaceAll(`{{${key}}}`, value)
const sectionMeta = (
request: GroupReportExportRequest,
key: keyof NonNullable<typeof request.report.sectionMeta>
): ReportSectionMeta | undefined => request.report.sectionMeta?.[key]
const sectionClass = (
request: GroupReportExportRequest,
key: keyof NonNullable<typeof request.report.sectionMeta>,
hasContent: boolean
): string => (sectionMeta(request, key)?.enabled && hasContent ? '' : 'empty-section')
const overflowNote = (
request: GroupReportExportRequest,
key: keyof NonNullable<typeof request.report.sectionMeta>
): string => {
const meta = sectionMeta(request, key)
if (request.metadata.reportMode !== 'compact' || !meta?.hiddenCount) return ''
return `<div class="section-more">另有 ${meta.hiddenCount} 条内容,请在完整版中查看</div>`
}
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
const { report, metadata } = request
const avatarNames = new Set<string>(metadata.heroParticipants)
@@ -147,6 +174,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
quote.messages.forEach((message) => avatarNames.add(message.sender))
)
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
const avatars = new Map<string, string>()
await Promise.all(
@@ -168,7 +198,38 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}</span></div>
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
<p>${escapeHtml(topic.summary)}</p>
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
${
topic.conclusions?.length
? `<div class="topic-conclusions">${topic.conclusions
.slice(0, 2)
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
.join('')}</div>`
: topic.conclusion
? `<div class="topic-conclusions"><div class="topic-conclusion">${escapeHtml(topic.conclusion)}</div></div>`
: ''
}
${
topic.image
? (() => {
// 优先用已有 imageUrl;若有 imageHash(来自 visionGallery),按 hash 取原图
let imageUrl = topic.image.imageUrl
if (!imageUrl && topic.image.imageHash) {
const insight = imageInsightService.getInsight(topic.image.imageHash)
if (insight) {
// insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取
// 走 ImageDecryptService.findImageFile + decryptImageToBase64
const decryptService = (globalThis as { __imageDecrypt?: { findImageFile: (md5?: string, dat?: string) => string | null; decryptImageToBase64: (p: string) => string | null } }).__imageDecrypt
if (decryptService) {
const filePath = decryptService.findImageFile(insight.md5, insight.datName)
if (filePath) imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
}
}
}
if (!imageUrl) return ''
return `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
})()
: ''
}
<div class="participants">${topic.participants
.slice(0, 5)
.map(
@@ -213,18 +274,122 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
)
.join('')
const qaCards = report.qa
const todoCards = (report.todos || [])
.map(
(item) =>
`<div class="qa-card"><b>Q${escapeHtml(item.question)}</b><div>A${escapeHtml(item.answer)}${item.answerer ? `${escapeHtml(item.answerer)}` : ''}</div></div>`
(item) => `<div class="action-card todo-card">
<b>${escapeHtml(item.task)}</b>
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
</div>`
)
.join('')
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
const heatBars = report.analytics.topicHeat
const unresolvedCards = (report.unresolved || [])
.map(
(item) =>
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
(item) => `<div class="action-card unresolved-card">
<b>${escapeHtml(item.question)}</b>
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
<div class="action-note">${escapeHtml(item.note)}</div>
</div>`
)
.join('')
const storylineCards = (report.storylines || [])
.map(
(item) => `<div class="card storyline-card">
<div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div>
<div class="storyline-steps">${item.stages
.map(
(stage) => `<div class="storyline-step">
<span>${escapeHtml(stage.time || '--:--')}</span>
<b>${escapeHtml(stage.event)}</b>
</div>`
)
.join('')}</div>
${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}
</div>`
)
.join('')
const reversalCards = (report.reversals || [])
.map(
(item) => `<div class="qa-card">
<b>${escapeHtml(item.topic)}</b>
<div>${escapeHtml(item.initialView)}</div>
<div>${escapeHtml(item.finalView)}</div>
${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}
</div>`
)
.join('')
const chainCards = (report.participantChains || [])
.map(
(item) => `<div class="card chain-card">
<div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div>
<div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>
${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}
</div>`
)
.join('')
const galleryCards = (report.media?.gallery || [])
.map(
(item) => `<div class="gallery-card">
<img class="gallery-image" src="${item.imageUrl}" alt="群聊图片">
<div class="gallery-body">
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}
<div class="important-text">${escapeHtml(item.note)}</div>
${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}
</div>
</div>`
)
.join('')
// AI 图片理解结果板块(ImageInsight)
// 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
const visionCards = (report.media?.visionGallery || [])
.filter((item) => item.imageUrl) // 只显示加载成功的图
.map(
(item) => `<div class="vision-card">
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
<div class="vision-body">
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
<div class="vision-description">${escapeHtml(item.description)}</div>
${item.ocrText ? `<div class="vision-ocr">📝 ${escapeHtml(item.ocrText)}</div>` : ''}
${item.tags.length ? `<div class="vision-tags">${item.tags.map((t) => `<span class="vision-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
<div class="vision-label">AI </div>
</div>
</div>`
)
.join('')
const voiceCards = (report.media?.voiceHighlights || [])
.map(
(item) => `<div class="qa-card">
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
<div>${escapeHtml(item.note)}</div>
</div>`
)
.join('')
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
.map(
(item, index) => `<div class="rank">
<img src="${avatar(item.sender)}" alt="">
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
<span>${item.count} · ${item.durationSec} </span>
</div>`
)
.join('')
const badgeCards = (report.media?.funBadges || [])
.map(
(item) => `<div class="badge-card">
<span class="tag">${escapeHtml(item.title)}</span>
<b>${escapeHtml(item.owner)}</b>
<p>${escapeHtml(item.note)}</p>
</div>`
)
.join('')
@@ -236,6 +401,20 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
)
.join('')
// v1 模板使用的水平条形热度图,渲染 top speakers 排行
const heatBarsHtml = report.analytics.topSpeakers
.slice(0, 8)
.map((speaker) => {
const count = Math.max(0, speaker.count)
const width = Math.min(100, count * 12)
return `<div class="heat-row">
<span class="heat-name">${escapeHtml(speaker.name)}</span>
<span class="heat-bar"><i style="width:${width}%"></i></span>
<span class="heat-val">${count}</span>
</div>`
})
.join('')
const cloudTags = report.keywords
.slice(0, 15)
.map(
@@ -244,35 +423,116 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
)
.join('')
let html = await fs.readFile(templatePath(), 'utf8')
const qaCards = report.qa
.map(
(item) =>
`<div class="qa-card"><b>Q${escapeHtml(item.question)}</b><div>A${escapeHtml(item.answer)}${item.answerer ? `${escapeHtml(item.answerer)}` : ''}</div></div>`
)
.join('')
const summaryStats = report.summaryStats || {
messageCount: metadata.messageCount,
activeUsers: metadata.activeUsers,
topicCount: report.topics.length,
mediaCount: metadata.mediaMessageCount || 0,
imageCount: metadata.imageCount || 0,
voiceCount: metadata.voiceCount || 0,
stickerCount: metadata.stickerCount || 0,
conclusionCount: 0,
todoCount: report.todos.length,
unresolvedCount: report.unresolved.length
}
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
const values: Record<string, string> = {
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange),
RECORD_NOTE: escapeHtml(`基于 WechatExplorer 加载的 ${metadata.messageCount} 条记录`),
OVERVIEW: escapeHtml(report.overview),
RECORD_NOTE: escapeHtml(metadata.recordNote),
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
// v2 模板使用的 hero-*
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''),
HERO_PENDING: escapeHtml(report.hero?.pendingNote || ''),
HERO_STATUS_LINE: escapeHtml(report.hero?.statusLine || ''),
HERO_STATUS_EMPTY_CLASS: report.hero?.statusLine ? '' : 'empty-section',
HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(metadata.messageCount),
ACTIVE_USERS: String(metadata.activeUsers),
MESSAGE_COUNT: String(summaryStats.messageCount),
ACTIVE_USERS: String(summaryStats.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
TOPIC_COUNT: String(report.topics.length),
TOPIC_COUNT: String(summaryStats.topicCount),
MEDIA_COUNT: String(summaryStats.mediaCount),
TOPIC_CARDS: topicCards,
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
TOPICS_EMPTY_CLASS: sectionClass(request, 'topics', report.topics.length > 0),
TOPICS_MORE_NOTE: overflowNote(request, 'topics'),
RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
RESOURCE_ITEMS: resourceItems,
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
IMPORTANT_MESSAGES: importantMessages,
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
QUOTE_BLOCKS: quoteBlocks,
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
ACTIONS_EMPTY_CLASS: sectionClass(request, 'actions', report.todos.length + report.unresolved.length > 0),
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
TODO_CARDS: todoCards,
UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
UNRESOLVED_CARDS: unresolvedCards,
ACTIONS_MORE_NOTE: overflowNote(request, 'actions'),
QA_EMPTY_CLASS: sectionClass(request, 'qa', report.qa.length > 0),
QA_CARDS: qaCards,
HEAT_BARS: heatBars,
QA_MORE_NOTE: overflowNote(request, 'qa'),
STORYLINES_EMPTY_CLASS: sectionClass(request, 'storylines', report.storylines?.length > 0),
STORYLINE_CARDS: storylineCards,
STORYLINES_MORE_NOTE: overflowNote(request, 'storylines'),
REVERSALS_EMPTY_CLASS: sectionClass(request, 'reversals', report.reversals?.length > 0),
REVERSAL_CARDS: reversalCards,
REVERSALS_MORE_NOTE: overflowNote(request, 'reversals'),
CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0),
CHAIN_CARDS: chainCards,
CHAINS_MORE_NOTE: overflowNote(request, 'chains'),
// AI 图片识别板块
VISION_EMPTY_CLASS: sectionClass(
request,
'vision',
(report.media?.visionGallery?.length ?? 0) > 0
),
VISION_CARDS: visionCards,
VISION_TITLE: '📸 AI 识别的图片精选',
GALLERY_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
GALLERY_CARDS: galleryCards,
GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: overflowNote(request, 'voices'),
VOICE_RANK_EMPTY_CLASS: sectionClass(request, 'voices', report.analytics.voiceLeaderboard?.length > 0),
VOICE_RANK_CARDS: voiceRankCards,
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
BADGE_CARDS: badgeCards,
BADGES_MORE_NOTE: overflowNote(request, 'badges'),
RANK_ITEMS: rankItems,
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
CONCLUSION_COUNT: String(summaryStats.conclusionCount),
TODO_COUNT: String(summaryStats.todoCount),
UNRESOLVED_COUNT: String(summaryStats.unresolvedCount),
CLOUD_TAGS: cloudTags,
KEYWORDS_EMPTY_CLASS: sectionClass(request, 'keywords', report.keywords.length > 0),
KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'),
ANALYTICS_EMPTY_CLASS: sectionClass(request, 'analytics', true),
GENERATED_AT: escapeHtml(metadata.generatedAt),
FOOTER_NOTE: escapeHtml(metadata.footerNote)
FOOTER_NOTE: escapeHtml(metadata.footerNote),
// v1 模板独有:从 analytics.topSpeakers 渲染水平条形热度图
HEAT_BARS: heatBarsHtml
}
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
return html
}
@@ -327,7 +587,8 @@ export const exportGroupReport = async (
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2'
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const htmlStartedAt = new Date()
+64 -4
View File
@@ -9,6 +9,8 @@ import {
} from './services/chat-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { generateAgentGroupReport } from './services/agent-group-report-service'
import { agentHubService } from './services/agent-hub-service'
import { safeError, safeLog, safeWarn } from './safe-log'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
@@ -157,7 +159,9 @@ const routes: Record<string, RouteHandler> = {
if (keyword) {
const lower = keyword.toLowerCase()
groups = groups.filter(
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) ||
c.m_nsUsrName.toLowerCase().includes(lower)
)
}
sendJson(res, 200, { count: groups.length, chatrooms: groups })
@@ -236,13 +240,62 @@ const routes: Record<string, RouteHandler> = {
try {
request = JSON.parse(body) as GroupReportExportRequest
} catch (error) {
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
return sendError(
res,
400,
'请求体 JSON 解析失败',
error instanceof Error ? error.message : String(error)
)
}
if (!request?.report || !request?.metadata) {
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
},
'/api/v1/agent/group-report': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
try {
request = JSON.parse(typeof body === 'string' ? body : '{}')
} catch {
return sendError(res, 400, '请求体 JSON 解析失败')
}
const result = await generateAgentGroupReport({
group: request.group || '',
range: request.range
})
sendJson(res, result.success ? 200 : 400, result)
},
'/api/v1/agent/status': ({ res }) => {
const status = agentHubService.getStatus()
sendJson(res, 200, {
ok: status.hub === 'online' && status.connector === 'online',
hub: status.hub,
connector: status.connector,
dataApi: status.dataApi,
databaseReady: status.databaseReady,
accountId: status.accountId
})
},
'/api/v1/agent/send': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let request: { to?: string; text?: string; media_url?: string }
try {
request = JSON.parse(typeof body === 'string' ? body : '{}')
} catch {
return sendError(res, 400, '请求体 JSON 解析失败')
}
const result = await agentHubService.testSend({
to: request.to,
text: request.text,
mediaUrl: request.media_url
})
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
}
}
@@ -311,7 +364,11 @@ export interface ApiServerState {
}
let singleton: HttpServerHandle | null = null
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
let singletonState: ApiServerState = {
running: false,
host: DEFAULT_HTTP_HOST,
port: DEFAULT_HTTP_PORT
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -326,7 +383,10 @@ export const apiServer = {
return { ...singletonState }
},
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
async start(
host: string = DEFAULT_HTTP_HOST,
port: number = DEFAULT_HTTP_PORT
): Promise<ApiServerState> {
if (singleton) {
return this.getState()
}
File diff suppressed because it is too large Load Diff
+825 -114
View File
File diff suppressed because it is too large Load Diff
+53 -4
View File
@@ -889,7 +889,8 @@ export class KeyService {
const dirName = normalized.split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) pushUnique(dirName)
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
// 仅支持 WeChat 4.0:路径识别只匹配 xwechat_files
const marker = normalized.match(/[\\/]xwechat_files/i)
if (marker) {
const root = normalized.slice(0, marker.index! + marker[0].length)
try {
@@ -934,15 +935,49 @@ export class KeyService {
onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(userDir, 32)
let { ciphertext, xorKey } = result
const firstDiag = (this as { _imageTemplateDiag?: {
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
} })._imageTemplateDiag
// 如果找不到密钥,尝试扫描更多文件
if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(userDir, 100)
xorKey = result.xorKey
}
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
if (!ciphertext) {
// 用诊断信息给具体提示
const diag = (this as { _imageTemplateDiag?: {
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
} })._imageTemplateDiag || firstDiag
if (!diag || diag.totalTFiles === 0) {
return {
success: false,
error:
'在账号目录下未找到任何 _t.dat 图片文件。\n' +
`扫描路径:${diag?.userDir || userDir || '(空)'}\n` +
'原因:微信没在本地生成缩略图。\n' +
'请让用户在微信里打开任意聊天的图片大图(等"原图"按钮可点击),然后再试。'
}
}
if (diag.v2Count === 0 && diag.nonV2Count > 0) {
return {
success: false,
error:
`找到 ${diag.totalTFiles} 个 _t.dat,但都不是 V2 头(可能图片尚未解密到本地,或微信版本不同)。\n` +
`扫描路径:${diag.userDir}\n` +
'请让用户在微信里打开 2-3 张不同的图片大图,等"原图"按钮可点击后再试。'
}
}
return {
success: false,
error:
`找到 ${diag.totalTFiles} 个 _t.dat,其中 ${diag.v2Count} 个是 V2 头,但没有长度 ≥ 0x1F 的有效模板。\n` +
`扫描路径:${diag.userDir}\n` +
'请在微信中查看更多图片后再试。'
}
}
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
@@ -1005,6 +1040,8 @@ export class KeyService {
let ciphertext: Buffer | null = null
const tailCounts: Record<string, number> = {}
let v2Count = 0
let nonV2Count = 0
for (const f of files.slice(0, 32)) {
try {
@@ -1013,8 +1050,11 @@ export class KeyService {
// 统计末尾两字节用于 XOR 密钥
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
v2Count++
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
tailCounts[key] = (tailCounts[key] ?? 0) + 1
} else {
nonV2Count++
}
// 提取密文(取第一个有效的)
@@ -1031,6 +1071,15 @@ export class KeyService {
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
}
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
const diag = {
userDir,
totalTFiles: files.length,
v2Count,
nonV2Count
}
;(this as { _imageTemplateDiag?: unknown })._imageTemplateDiag = diag
return { ciphertext, xorKey }
}
+343 -8
View File
@@ -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]
+12
View File
@@ -0,0 +1,12 @@
import { app } from 'electron'
import { existsSync } from 'fs'
import { join } from 'path'
export function isPackagedRuntime(): boolean {
if (app.isPackaged) return true
return (
existsSync(join(process.resourcesPath, 'app.asar')) &&
existsSync(join(process.resourcesPath, 'app-update.yml'))
)
}
+58
View File
@@ -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
}
}
@@ -0,0 +1,115 @@
import type { Contact, Message } from '../../shared/types'
import { exportGroupReport } from '../group-report-service'
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
import { AIProviderService } from './ai-provider-service'
import {
buildGroupReportInput,
getSummaryDateRange,
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
GROUP_REPORT_SYSTEM_PROMPT,
isInternalName,
parseGroupDailyReport,
type SummaryDateRange
} from '../../renderer/src/utils/group-report'
const aiProvider = new AIProviderService()
export interface AgentGroupReportRequest {
group: string
range?: SummaryDateRange
}
export interface AgentGroupReportResult {
success: boolean
groupName?: string
pngPath?: string
messageCount?: number
error?: string
}
export async function generateAgentGroupReport(
request: AgentGroupReportRequest
): Promise<AgentGroupReportResult> {
const query = String(request.group || '')
.trim()
.replace(/群聊?$/, '')
.trim()
if (!query) return { success: false, error: '缺少群聊名称' }
const contact = resolveMd5(query)
if (!contact) return { success: false, error: `没有找到群聊“${query}` }
if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) {
return { success: false, error: `${query}”不是群聊` }
}
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
const { startTime, endTime } = getSummaryDateRange(range)
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
const snapshot = getGroupSnapshot(contact.md5)
if (snapshot) {
const members = new Map(
snapshot.members.map((member) => [
member.wxid,
{ name: member.nickname, avatar: member.avatar }
])
)
messages = messages.map((message) => {
if (!isInternalName(message.name)) return message
const member = members.get(String(message.senderId || message.name || ''))
return member?.name
? { ...message, name: member.name, img: message.img || member.avatar }
: message
})
}
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
const ai = await aiProvider.chat([
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
])
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
parseGroupDailyReport(
raw,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard,
input.metadata,
input.media
)
let report: ReturnType<typeof parseGroupDailyReport>
try {
report = parseReport(ai.data)
} catch (parseError) {
const repaired = await aiProvider.chat([
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
{ role: 'user', content: ai.data }
])
if (!repaired.success || !repaired.data) {
const cause = parseError instanceof Error ? parseError.message : String(parseError)
return {
success: false,
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause}`
}
}
try {
report = parseReport(repaired.data)
} catch (repairError) {
return {
success: false,
error: repairError instanceof Error ? repairError.message : String(repairError)
}
}
}
const exported = await exportGroupReport({ report, metadata: input.metadata })
if (!exported.success || !exported.pngPath) {
return { success: false, error: exported.error || '总结图片生成失败' }
}
return {
success: true,
groupName: input.metadata.groupName,
pngPath: exported.pngPath,
messageCount: messages.length
}
}
File diff suppressed because it is too large Load Diff
+97 -21
View File
@@ -41,7 +41,6 @@ export class AIProviderService {
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
list(): AIProviderListResult {
this.ensureEnvironmentMigration()
try {
const data = this.readMetadata()
return {
@@ -69,7 +68,8 @@ export class AIProviderService {
configured: Boolean(
provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider))
),
status: provider?.status || 'untested'
status: provider?.status || 'untested',
timeoutMs: provider?.advanced.timeoutMs
}
}
@@ -169,6 +169,40 @@ export class AIProviderService {
}
}
/**
*
* 输入:text + image parts messages, AI
* testVision 区别:不校验 prompt, capability marker( ImageInsightService )
*/
async analyzeImage(
messages: Array<{
role: string
content: string | Array<{ type: 'text'; text: string } | { type: 'image'; dataUrl: string }>
}>,
options?: AIChatRequestOptions
): Promise<{
success: boolean
data?: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
error?: string
}> {
try {
const imagePart = messages
.flatMap((message) => (typeof message.content === 'string' ? [] : message.content))
.find((part) => part.type === 'image')
if (!imagePart || imagePart.type !== 'image') throw new Error('图片识别请求缺少图片数据')
const imageError = validateVisionImage(imagePart.dataUrl)
if (imageError) throw new Error(imageError)
const result = await this.request(messages as AIMessage[], options)
if (options?.providerId && options.modelId) {
this.markCapabilities(options.providerId, options.modelId, { vision: true, ocr: true })
}
return { success: true, ...result }
} catch (error) {
return { success: false, error: safeAIError(error) }
}
}
async testVision(request: AIVisionTestRequest): Promise<AIVisionTestResult> {
const startedAt = Date.now()
const imageError = validateVisionImage(request.imageDataUrl)
@@ -215,7 +249,13 @@ export class AIProviderService {
}> {
if (options?.apiKey) return this.requestLegacy(messages, options)
const resolved = this.resolveProvider(options)
return requestProvider(resolved.provider, resolved.key, resolved.model, messages, testing)
const provider = options?.timeoutMs
? {
...resolved.provider,
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
}
: resolved.provider
return requestProvider(provider, resolved.key, resolved.model, messages, testing)
}
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
@@ -263,24 +303,38 @@ export class AIProviderService {
}
private markVisionCapability(providerId: string, modelId: string): void {
this.markCapabilities(providerId, modelId, { vision: true, ocr: true })
}
/**
* capabilities()
* OCR vision:几乎所有 vision OCR, vision ocr
*/
private markCapabilities(
providerId: string,
modelId: string,
caps: { vision?: boolean; ocr?: boolean }
): void {
const data = this.readMetadata()
const provider = data.providers.find((item) => item.id === providerId)
const model = provider?.models.find((item) => item.id === modelId)
if (!provider || !model || model.capabilities.vision) return
model.capabilities.vision = true
this.writeMetadata(data)
}
private ensureEnvironmentMigration(): void {
const data = this.readMetadata()
if (data.providers.length) return
const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim()
if (!apiKey) return
this.migrateLegacy({
apiKey,
baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''),
model: String(import.meta.env.VITE_AI_MODEL || '')
})
if (!provider || !model) return
// 老配置可能没有 ocr 字段,补默认 false
if (typeof model.capabilities.ocr !== 'boolean') model.capabilities.ocr = false
let changed = false
if (caps.vision === true && !model.capabilities.vision) {
model.capabilities.vision = true
// vision 开启默认带 ocr(派生能力)
if (!model.capabilities.ocr) {
model.capabilities.ocr = true
}
changed = true
}
if (caps.ocr === true && !model.capabilities.ocr) {
model.capabilities.ocr = true
changed = true
}
if (changed) this.writeMetadata(data)
}
private toSummary(
@@ -300,6 +354,14 @@ export class AIProviderService {
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
if (data.version !== 1 || !Array.isArray(data.providers))
throw new Error('invalid provider metadata')
// 老配置兼容:补 capabilities.ocr 默认值(vision 派生 OCR)
for (const provider of data.providers) {
for (const model of provider.models) {
if (typeof model.capabilities.ocr !== 'boolean') {
model.capabilities.ocr = model.capabilities.vision === true
}
}
}
return data
}
@@ -325,7 +387,7 @@ function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
{
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
id: modelId,
capabilities: { chat: true, vision: false, longContext: true }
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
}
],
defaultModel: modelId,
@@ -454,7 +516,7 @@ async function requestOpenAICompatible(
},
provider.advanced.timeoutMs
)
const payload = (await response.json()) as OpenAIResponsePayload
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
return {
data: String(payload.choices?.[0]?.message?.content || ''),
@@ -508,7 +570,7 @@ async function requestAnthropic(
},
provider.advanced.timeoutMs
)
const payload = (await response.json()) as AnthropicResponsePayload
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
if (!response.ok)
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
return {
@@ -543,6 +605,20 @@ async function fetchWithTimeout(
}
}
async function parseJsonResponse<T>(response: Response): Promise<T> {
const body = await response.text()
try {
return JSON.parse(body) as T
} catch {
const looksLikeHtml = /^\s*(?:<!doctype\s+html|<html\b)/i.test(body)
const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`
if (looksLikeHtml) {
throw new Error(`模型服务返回了网页而不是 JSON(HTTP ${status}),请稍后重试或检查中转服务`)
}
throw new Error(`模型服务返回格式异常(HTTP ${status}`)
}
}
function safeAIError(error: unknown): string {
if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时'
const message = error instanceof Error ? error.message : String(error)
+120
View File
@@ -0,0 +1,120 @@
import { app, BrowserWindow } from 'electron'
import { autoUpdater, type ProgressInfo } from 'electron-updater'
import type { AppUpdateCheckResult, AppUpdateState } from '../../shared/app-update'
import { isPackagedRuntime } from '../runtime-mode'
export class AppUpdateService {
private state: AppUpdateState = {
status: 'idle',
currentVersion: app.getVersion()
}
constructor() {
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('checking-for-update', () => this.setState({ status: 'checking' }))
autoUpdater.on('update-available', (info) =>
this.setState({ status: 'available', version: info.version, message: '发现新版本' })
)
autoUpdater.on('update-not-available', () =>
this.setState({ status: 'not-available', message: '当前已是最新版本' })
)
autoUpdater.on('download-progress', (progress: ProgressInfo) =>
this.setState({
status: 'downloading',
percent: progress.percent,
transferred: progress.transferred,
total: progress.total,
bytesPerSecond: progress.bytesPerSecond
})
)
autoUpdater.on('update-downloaded', (info) =>
this.setState({
status: 'downloaded',
version: info.version,
percent: 100,
message: '更新已下载'
})
)
autoUpdater.on('error', (error) =>
this.setState({ status: 'error', message: error.message || '更新失败' })
)
}
getState(): AppUpdateState {
return { ...this.state, currentVersion: app.getVersion() }
}
async check(): Promise<AppUpdateCheckResult> {
if (!isPackagedRuntime()) {
const state = this.setState({
status: 'unsupported',
message: '开发模式不执行安装包更新,请在正式安装包中检查更新'
})
return { success: false, state }
}
try {
const result = await autoUpdater.checkForUpdates()
if (result?.updateInfo.version) {
this.setState({
status: 'available',
version: result.updateInfo.version,
message: '发现新版本'
})
}
return { success: true, state: this.getState() }
} catch (error) {
const state = this.setState({
status: 'error',
message: error instanceof Error ? error.message : String(error)
})
return { success: false, state }
}
}
async download(): Promise<AppUpdateCheckResult> {
if (!isPackagedRuntime()) {
const state = this.setState({ status: 'unsupported', message: '开发模式不能下载更新' })
return { success: false, state }
}
try {
this.setState({ status: 'downloading', percent: 0 })
await autoUpdater.downloadUpdate()
return { success: true, state: this.getState() }
} catch (error) {
const state = this.setState({
status: 'error',
message: error instanceof Error ? error.message : String(error)
})
return { success: false, state }
}
}
install(): { success: boolean; error?: string } {
if (this.state.status !== 'downloaded') {
return { success: false, error: '更新包尚未下载完成' }
}
autoUpdater.quitAndInstall()
return { success: true }
}
handleState(callback: (state: AppUpdateState) => void): () => void {
this.listeners.add(callback)
callback(this.getState())
return () => this.listeners.delete(callback)
}
private listeners = new Set<(state: AppUpdateState) => void>()
private setState(patch: Partial<AppUpdateState>): AppUpdateState {
this.state = { ...this.state, ...patch, currentVersion: app.getVersion() }
const state = this.getState()
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('app-update:state', state)
}
for (const listener of this.listeners) listener(state)
return state
}
}
export const appUpdateService = new AppUpdateService()
+467 -96
View File
@@ -11,103 +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 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)
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
return {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Number(raw.updatedAt) || 0,
self: raw.self,
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
}
} catch (error) {
console.warn('[BootstrapCache] read failed:', error)
return null
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 {
try {
const file = getCacheFile(cache.accountRoot)
fs.ensureDirSync(path.dirname(file))
fs.writeJsonSync(file, cache, { spaces: 2 })
} catch (error) {
console.warn('[BootstrapCache] write failed:', error)
}
}
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
return (
readCacheFile(normalizedRoot) || {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {}
}
)
}
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
}
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
const entries = Object.entries(messages)
if (entries.length <= MAX_MESSAGE_BUCKETS) return
entries
.sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0))
.slice(MAX_MESSAGE_BUCKETS)
.forEach(([key]) => {
delete messages[key]
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): {
@@ -115,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
}
}
@@ -135,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)
@@ -144,7 +438,9 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
)
const nameByUsername = new Map(
cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
.filter(
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
)
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
)
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
@@ -162,24 +458,26 @@ 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 || [])
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
cache.contacts
.filter(
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
)
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
)
cache.contacts = contacts.map((contact) => ({
@@ -194,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]
@@ -209,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(
@@ -218,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(
@@ -230,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()
}
+73
View File
@@ -0,0 +1,73 @@
import { app, session } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import { clearBootstrapCache } from './bootstrap-cache'
import type { CacheClearScope, CacheSummary, CacheSummaryItem } from '../../shared/cache'
export type { CacheClearScope } from '../../shared/cache'
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
if (!fs.existsSync(directory)) return { sizeBytes: 0, fileCount: 0 }
let sizeBytes = 0
let fileCount = 0
const visit = (current: string): void => {
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const target = path.join(current, entry.name)
if (entry.isDirectory()) {
visit(target)
} else if (entry.isFile()) {
try {
sizeBytes += fs.statSync(target).size
fileCount += 1
} catch {
// A cache file can disappear while it is being inspected.
}
}
}
}
visit(directory)
return { sizeBytes, fileCount }
}
export function getCacheSummary(): CacheSummary {
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
const items: CacheSummaryItem[] = [
{
id: 'bootstrap',
label: '启动与聊天缓存',
description: '联系人、头像、群成员和最近聊天记录的本地副本。',
...bootstrap
},
{
id: 'electron',
label: '应用临时缓存',
description: 'Electron 页面资源缓存,清理后会自动重新生成。',
...electron
}
]
return {
items,
totalBytes: items.reduce((total, item) => total + item.sizeBytes, 0),
updatedAt: Date.now()
}
}
export async function clearCache(scope: CacheClearScope): Promise<CacheSummary> {
if (scope === 'bootstrap' || scope === 'all') {
clearBootstrapCache()
await fs.remove(BOOTSTRAP_CACHE_DIR)
}
if (scope === 'electron' || scope === 'all') {
await session.defaultSession.clearCache()
}
return getCacheSummary()
}
+206 -32
View File
@@ -1,5 +1,6 @@
import { WechatDb, WechatMessage } from '../wechat-db'
import {
parseImageBufferDataUrlFromRow,
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
@@ -8,6 +9,7 @@ import type {
DatabaseKeyValidationCode,
DatabaseKeyValidationResult
} from '../../shared/database-key'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
export function getCurrentKey(): string {
if (!dbRef) return ''
@@ -33,6 +35,10 @@ export interface FormattedContact {
md5: string
type: 'user' | 'group'
avatar?: string
wechatNickname?: string
remark?: string
isFolded?: boolean
isMuted?: boolean
}
export interface FormattedMessage {
@@ -48,15 +54,30 @@ 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 {
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
members: {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}[]
}
const MSG_TYPE_DICT: Record<number, string> = {
@@ -119,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 []
@@ -171,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`
)
@@ -209,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|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const parsed =
inferredMsgType === 47
const isQuotePayload = /<refermsg\b/i.test(content)
const hasStickerPayload =
/<(?:emoji|sticker|emoticon)\b/i.test(content) || /<type>\s*47\s*<\/type>/i.test(content)
const rowSticker =
inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
? parseStickerMessageFromRow(msg, content)
: parseMessageContent(content, inferredMsgType)
: undefined
const parsedContent = parseMessageContent(content, inferredMsgType)
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
const redPacketUrl = rowStickerUrl || parsedShareUrl
const isRedPacketFallback =
(parsedContent.type === 'share' && parsedContent.typeVal === '2001') ||
/wxapp\.tenpay\.com\/mmpayhb/i.test(redPacketUrl)
const parsed: ReturnType<typeof parseMessageContent> =
parsedContent.type === 'miniProgram' || parsedContent.type === 'redPacket'
? parsedContent
: isRedPacketFallback
? {
type: 'redPacket',
title:
parsedContent.type === 'share' && parsedContent.title
? parsedContent.title
: '微信红包',
description:
parsedContent.type === 'share' && parsedContent.des
? parsedContent.des
: '恭喜发财,大吉大利',
url: redPacketUrl || undefined
}
: rowSticker?.type === 'sticker'
? rowSticker
: parsedContent
if (parsed.type === 'system') {
content = parsed.content
contentData = parsed
} 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)
@@ -238,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
}
@@ -255,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,
@@ -269,7 +392,9 @@ export function listMessages(
senderId,
sessionId: username,
localId,
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
createTime,
recoveredFromRecallJournal,
contentData
}
})
@@ -280,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()
@@ -292,12 +444,34 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
.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 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.xWCDB' : '未检测到'
}
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 {
+94
View File
@@ -0,0 +1,94 @@
// src/main/services/image-insight-prompt.ts
// 图片理解 prompt 模板 — 输出严格的 JSON,便于程序化解析
export const IMAGE_ANALYSIS_SYSTEM_PROMPT = `你是微信群聊的图片分析助手。
,
():
1. JSON, markdown
2. description:1-2 ,30-80 ,
3. ocrText:如果图片含文字(),;/
4. tags:3-6
5. category:screenshot / photo / meme / document / chart / other
6. importance:low / medium / high
:
-
- ( description )
- markdown `
export interface ImageAnalysisContext {
sender: string
sentAt: number
contextBefore: string[] // 图片前 1-3 条消息
contextAfter: string[] // 图片后 1-3 条消息
}
export function buildImageAnalysisUserText(ctx: ImageAnalysisContext): string {
const before = ctx.contextBefore.length
? ctx.contextBefore.map((m, i) => ` ${i + 1}. ${m}`).join('\n')
: ' (无前文)'
const after = ctx.contextAfter.length
? ctx.contextAfter.map((m, i) => ` ${i + 1}. ${m}`).join('\n')
: ' (无后续讨论)'
const time = new Date(ctx.sentAt * 1000).toLocaleString('zh-CN', { hour12: false })
return `发送者:${ctx.sender}
时间:${time}
:
${before}
:
${after}
JSON( system ):
{"description":"...","ocrText":"...","tags":["..."],"category":"...","importance":"..."}`
}
/**
* AI
* 容忍: markdown markdown
*/
export function parseImageAnalysisResponse(raw: string): {
description: string
ocrText: string
tags: string[]
category: 'screenshot' | 'photo' | 'meme' | 'document' | 'chart' | 'other'
importance: 'low' | 'medium' | 'high'
} {
const text = raw.trim()
// 提取 JSON 段
const jsonMatch = text.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
throw new Error('AI 未返回合法 JSON')
}
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(jsonMatch[0])
} catch {
throw new Error('AI 返回的 JSON 无法解析')
}
const description = String(parsed.description || '').trim()
if (!description) throw new Error('AI 未返回 description')
const ocrText = String(parsed.ocrText || '').trim()
const tagsRaw = parsed.tags
const tags = Array.isArray(tagsRaw)
? tagsRaw.map((t) => String(t).trim()).filter(Boolean).slice(0, 8)
: []
const categoryRaw = String(parsed.category || 'other').toLowerCase()
const category: 'screenshot' | 'photo' | 'meme' | 'document' | 'chart' | 'other' =
['screenshot', 'photo', 'meme', 'document', 'chart'].includes(categoryRaw)
? (categoryRaw as 'screenshot' | 'photo' | 'meme' | 'document' | 'chart')
: 'other'
const importanceRaw = String(parsed.importance || 'medium').toLowerCase()
const importance: 'low' | 'medium' | 'high' = ['low', 'medium', 'high'].includes(importanceRaw)
? (importanceRaw as 'low' | 'medium' | 'high')
: 'medium'
return { description, ocrText, tags, category, importance }
}
+279
View File
@@ -0,0 +1,279 @@
// src/main/services/image-insight-service.ts
// WechatExplorer AI 图片理解基础设施
//
// 设计原则:
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
// 2. 同图(imageHash)走缓存,绝不重复调 AI
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
// 4. 第一阶段:Top 3 热点图 + 缓存命中即返回,未命中并发调 AI
import crypto from 'crypto'
import { randomUUID } from 'crypto'
import { imageInsightsStore } from '../db/image-insights-store'
import {
buildImageAnalysisUserText,
IMAGE_ANALYSIS_SYSTEM_PROMPT,
parseImageAnalysisResponse
} from './image-insight-prompt'
import type {
ImageAnalysisRequest,
ImageAnalysisResponse,
ImageCandidate,
ImageCandidateQuery,
ImageInsight
} from '../../shared/image-insight'
/**
* ( renderer messages main)
* ImageInsightService ,
*/
export interface ImageCandidateInput {
messageId: string
md5?: string
datName?: string
sessionId: string
sender: string
sentAt: number
/** 图片发出后 8 条消息内、不同发言人的回复数(由 renderer 计算) */
responseCount: number
/** 表情/语音互动条数 */
interactionCount: number
}
interface ProviderServiceLike {
list(): ProviderSummaryLike
analyzeImage(
messages: Array<{
role: string
content: string | Array<{ type: 'text'; text: string } | { type: 'image'; dataUrl: string }>
}>,
options?: { providerId?: string; modelId?: string }
): Promise<{
success: boolean
data?: string
error?: string
}>
}
interface DecryptServiceLike {
findImageFile(
md5?: string,
imageDatName?: string,
options?: { allowThumbnail?: boolean }
): string | null
decryptImageToBase64(datPath: string): string | null
}
interface ProviderSummaryLike {
providers: Array<{
id: string
isDefault: boolean
defaultModel: string
models: Array<{ id: string; capabilities: { vision: boolean; ocr: boolean } }>
}>
defaultProviderId?: string
}
class ImageInsightService {
private providerService: ProviderServiceLike | null = null
private decryptService: DecryptServiceLike | null = null
/** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */
private runtimeProviderId: string | undefined = undefined
private runtimeModelId: string | undefined = undefined
/** 注入依赖(由 main/index.ts 在 app ready 后调用) */
bind(deps: {
providerService: ProviderServiceLike & { list(): ProviderSummaryLike }
decryptService: DecryptServiceLike
}): void {
this.providerService = deps.providerService
this.decryptService = deps.decryptService
console.log(
'[ImageInsightService] bind ok, default provider=%s model=%s',
this.runtimeProviderId,
this.runtimeModelId
)
// 读取默认 provider/model(后续 analyze 时使用)
try {
const list = deps.providerService.list()
const provider =
list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0]
this.runtimeProviderId = provider?.id
this.runtimeModelId = provider?.defaultModel
console.log(
'[ImageInsightService] bind loaded default provider=%s model=%s',
this.runtimeProviderId,
this.runtimeModelId
)
} catch (error) {
console.warn('[ImageInsightService] bind list failed:', error)
}
}
/**
* key:imageHash
* 策略:优先微信原始 md5, md5 sha256(rawBytes).slice(0, 32)
*/
private async computeImageHash(
md5: string | undefined,
datName: string | undefined
): Promise<string | null> {
if (md5 && md5.trim()) return md5.trim().toLowerCase()
if (!this.decryptService) return null
const filePath = this.decryptService.findImageFile(undefined, datName, { allowThumbnail: true })
if (!filePath) return null
// 一次性读盘 + sha256(只在没有 md5 时才付出 IO)
try {
const fs = await import('fs-extra')
const buf = await fs.readFile(filePath)
const sha = crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)
return `sha256:${sha}`
} catch (error) {
console.warn('[ImageInsightService] computeImageHash failed:', error)
return null
}
}
/** 通过 hash 拿 Insight(只读缓存,无 AI 调用) */
getInsight(imageHash: string): ImageInsight | null {
return imageInsightsStore.getByHash(imageHash)
}
/**
* 主入口:分析一张图片
* 1. imageHash ,
* 2. 未命中:解密图片 AI
* 3. 任意步骤失败:记录日志, success=false,****
*/
async analyze(request: ImageAnalysisRequest): Promise<ImageAnalysisResponse> {
try {
if (!request.force) {
const cached = imageInsightsStore.getByHash(request.imageHash)
if (cached) {
return { success: true, insight: cached, fromCache: true }
}
}
if (!this.providerService) {
return { success: false, error: 'AI Provider 未初始化' }
}
const messages = [
{
role: 'system',
content: IMAGE_ANALYSIS_SYSTEM_PROMPT
},
{
role: 'user',
content: [
{
type: 'text' as const,
text: buildImageAnalysisUserText({
sender: request.sender,
sentAt: request.sentAt,
contextBefore: [],
contextAfter: []
})
},
{ type: 'image' as const, dataUrl: request.imageDataUrl }
]
}
]
const list = this.providerService.list()
const provider =
list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0]
this.runtimeProviderId = provider?.id
this.runtimeModelId = provider?.defaultModel
const result = await this.providerService.analyzeImage(messages, {
providerId: this.runtimeProviderId,
modelId: this.runtimeModelId
})
if (!result.success || !result.data) {
console.warn('[ImageInsightService] analyze vision failed: %s', result.error || 'no data')
return { success: false, error: result.error || 'AI 未返回内容' }
}
console.log('[ImageInsightService] analyze ok, description=%s', result.data.slice(0, 80))
const parsed = parseImageAnalysisResponse(result.data)
const insight: ImageInsight = {
id: randomUUID(),
messageId: request.messageId,
imageHash: request.imageHash,
md5: undefined,
datName: undefined,
description: parsed.description,
ocrText: parsed.ocrText || undefined,
tags: parsed.tags,
category: parsed.category,
importance: parsed.importance,
provider: this.runtimeProviderId || '',
model: this.runtimeModelId || '',
createdAt: Date.now(),
updatedAt: Date.now(),
sender: request.sender,
sentAt: request.sentAt,
sessionId: request.sessionId
}
imageInsightsStore.upsert(insight)
return { success: true, insight, fromCache: false }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.warn('[ImageInsightService] analyze failed:', message)
return { success: false, error: message }
}
}
/**
* 日报入口: renderer Top N + Insight
*
* 设计:不自己查 chat-service(), renderer messages
* + , userMd5/sessionId
*/
async listTopHotImages(
query: ImageCandidateQuery,
inputs: ImageCandidateInput[] = []
): Promise<ImageCandidate[]> {
const limit = query.limit ?? 3
const candidates: ImageCandidate[] = []
console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length)
for (const input of inputs) {
const hash = await this.computeImageHash(input.md5, input.datName)
if (!hash) {
console.log(
'[ImageInsightService] skip %s: hash empty (md5=%s datName=%s)',
input.messageId,
input.md5,
input.datName
)
continue
}
const heatScore = input.responseCount * 3 + input.interactionCount * 2 + 1
const candidate: ImageCandidate = {
messageId: input.messageId,
imageHash: hash,
md5: input.md5,
datName: input.datName,
sessionId: input.sessionId,
sender: input.sender,
sentAt: input.sentAt,
heatScore
}
const cached = imageInsightsStore.getByHash(hash)
if (cached) candidate.insight = cached
candidates.push(candidate)
}
candidates.sort((a, b) => b.heatScore - a.heatScore)
return candidates.slice(0, limit)
}
/**
* insights(, UI )
*/
listBySession(sessionId: string, limit?: number): ImageInsight[] {
return imageInsightsStore.listBySession(sessionId, limit)
}
}
export const imageInsightService = new ImageInsightService()
@@ -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
+5 -2
View File
@@ -8,6 +8,7 @@ import {
} from '../../shared/local-api-test'
const REQUEST_TIMEOUT_MS = 10_000
const GROUP_REPORT_TIMEOUT_MS = 180_000
const MAX_BODY_SIZE = 512 * 1024
function isEndpointId(value: unknown): value is LocalApiEndpointId {
@@ -122,7 +123,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
})
}
)
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
const timeoutMs =
endpointId === 'agent-group-report' ? GROUP_REPORT_TIMEOUT_MS : REQUEST_TIMEOUT_MS
request.setTimeout(timeoutMs, () => {
request.destroy(new Error('请求超时'))
finish({
ok: false,
@@ -132,7 +135,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
durationMs: Date.now() - startedAt,
responseSize: 0,
errorCode: 'TIMEOUT',
error: '请求超时(10 秒)'
error: `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
})
})
request.on('error', (error: NodeJS.ErrnoException) => {
+472
View File
@@ -0,0 +1,472 @@
import { app } from 'electron'
import crypto from 'crypto'
import fs from 'fs-extra'
import path from 'path'
import type { Message } from '../../shared/types'
type RecallRecord = {
targetIds: string[]
actor?: string
noticeId: string
createTime: number
}
type ArchivedMessage = Message & {
archivedAt: number
recallNotice?: boolean
}
type SessionArchive = {
username: string
updatedAt: number
messages: ArchivedMessage[]
recalls: RecallRecord[]
}
type RecallArchiveFile = {
version: 1
accountRoot: string
updatedAt: number
sessions: Record<string, SessionArchive>
}
const ARCHIVE_VERSION = 1
const MAX_MESSAGES_PER_SESSION = 3000
const MAX_RECALLS_PER_SESSION = 500
const WRITE_DEBOUNCE_MS = 250
let archive: RecallArchiveFile | null = null
let archivePath = ''
let writeTimer: NodeJS.Timeout | null = null
let writeQueue: Promise<void> = Promise.resolve()
function messageIdentity(message: Message): string {
if (message.recoveredFromRecallJournal) {
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
}
if (message.localId) return `local:${message.localId}`
if (message.serverId) return `server:${message.serverId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
function messageTargetIds(message: Message): string[] {
return Array.from(
new Set(
[
message.serverId,
message.localId ? String(message.localId) : '',
message.id ? String(message.id) : ''
].filter((value): value is string => Boolean(value))
)
)
}
function isRecallNotice(message: Message): boolean {
return message.contentData?.type === 'system' && Boolean(message.contentData.recall)
}
function matchesRecall(message: Message, recall: RecallRecord): boolean {
const messageIds = messageTargetIds(message)
return recall.targetIds.some((targetId) => messageIds.includes(targetId))
}
function inferRecallTarget(
messages: Iterable<ArchivedMessage>,
notice: Message,
actor?: string,
claimedTargetIds: Set<string> = new Set()
): ArchivedMessage | undefined {
const noticeTime = notice.createTime || 0
const expectsMine = /^(你|我)$/.test(String(actor || '').trim()) || /^你撤回/.test(notice.content)
const eligible = Array.from(messages)
.filter((message) => {
if (message.type === '系统消息' || message.from === 'system' || message.recallNotice)
return false
if (!message.recoveredFromRecallJournal) return false
if (messageTargetIds(message).some((targetId) => claimedTargetIds.has(targetId))) return false
if (noticeTime && message.createTime && message.createTime > noticeTime) return false
if (noticeTime && message.createTime && noticeTime - message.createTime > 180) return false
return true
})
.sort((left, right) => {
const timeDelta = (right.createTime || 0) - (left.createTime || 0)
if (timeDelta) return timeDelta
return (right.localId || 0) - (left.localId || 0)
})
const strict = eligible.find((message) => {
if (expectsMine) return message.isSender
if (!actor) return true
const names = [message.name, message.senderId].map((value) => String(value || '').trim())
return !names.some(Boolean) || names.includes(actor)
})
// Journal rows are captured at deletion time, so the fallback never points at
// an unrelated visible message merely because it happened to be nearby.
return strict || eligible[0]
}
function annotateFromRecall(message: Message, recalls: RecallRecord[]): Message {
if (isRecallNotice(message)) return message
const recall = recalls.find((item) => matchesRecall(message, item))
if (!recall) return message
const recalledByMe = /^(你|我)$/.test(String(recall.actor || '').trim())
return {
...message,
from: recalledByMe ? 'assistant' : message.from,
isSender: recalledByMe ? true : message.isSender,
recalled: true,
recalledBy: recall.actor
}
}
function scheduleWrite(): void {
if (!archive || !archivePath) return
if (writeTimer) clearTimeout(writeTimer)
writeTimer = setTimeout(() => {
writeTimer = null
if (!archive || !archivePath) return
const targetPath = archivePath
const serialized = JSON.stringify(archive)
writeQueue = writeQueue
.catch(() => undefined)
.then(async () => {
await fs.ensureDir(path.dirname(targetPath))
const temporaryPath = `${targetPath}.tmp`
await fs.writeFile(temporaryPath, serialized, 'utf8')
await fs.move(temporaryPath, targetPath, { overwrite: true })
})
.catch((error) => {
console.warn('[RecallArchive] write failed:', error)
})
}, WRITE_DEBOUNCE_MS)
}
function loadArchive(accountRoot: string): RecallArchiveFile {
const hash = crypto
.createHash('sha1')
.update(`${process.platform}:${accountRoot}`)
.digest('hex')
.slice(0, 16)
archivePath = path.join(app.getPath('userData'), 'recall-archive', `${hash}.json`)
try {
if (fs.existsSync(archivePath)) {
const stored = fs.readJsonSync(archivePath) as Partial<RecallArchiveFile>
if (
stored.version === ARCHIVE_VERSION &&
stored.accountRoot === accountRoot &&
stored.sessions &&
typeof stored.sessions === 'object'
) {
return stored as RecallArchiveFile
}
}
} catch (error) {
console.warn('[RecallArchive] read failed:', error)
}
return {
version: ARCHIVE_VERSION,
accountRoot,
updatedAt: Date.now(),
sessions: {}
}
}
export function configureRecallArchive(accountRoot: string): void {
const normalizedRoot = String(accountRoot || '').trim()
if (!normalizedRoot) {
archive = null
archivePath = ''
return
}
if (archive?.accountRoot === normalizedRoot) return
archive = loadArchive(normalizedRoot)
}
export function recordRecallArchiveMessages(
sessionMd5: string,
username: string,
messages: Message[]
): void {
if (!archive || !sessionMd5 || messages.length === 0) return
const bucket =
archive.sessions[sessionMd5] ||
({
username,
updatedAt: Date.now(),
messages: [],
recalls: []
} satisfies SessionArchive)
if (username) bucket.username = username
const byIdentity = new Map(bucket.messages.map((message) => [messageIdentity(message), message]))
let changed = false
const targetlessNoticeIds = new Set(
messages
.filter((message) => {
const recall =
message.contentData?.type === 'system' ? message.contentData.recall : undefined
return Boolean(recall && !(recall.targetIds?.length || recall.targetId))
})
.map(messageIdentity)
)
if (targetlessNoticeIds.size > 0) {
const retained = bucket.recalls.filter((record) => !targetlessNoticeIds.has(record.noticeId))
if (retained.length !== bucket.recalls.length) {
bucket.recalls = retained
changed = true
}
}
for (const sourceMessage of messages) {
const recallData =
sourceMessage.contentData?.type === 'system' ? sourceMessage.contentData.recall : undefined
if (recallData) {
let targetIds = Array.from(
new Set([...(recallData.targetIds || []), recallData.targetId || ''].filter(Boolean))
)
const noticeId = messageIdentity(sourceMessage)
const noticeTargetIds = new Set(messageTargetIds(sourceMessage))
const existingRecordIndex = bucket.recalls.findIndex((record) => record.noticeId === noticeId)
const existingRecord =
existingRecordIndex >= 0 ? bucket.recalls[existingRecordIndex] : undefined
const existingTargetsNotice =
existingRecord &&
existingRecord.targetIds.length > 0 &&
existingRecord.targetIds.every((targetId) => noticeTargetIds.has(targetId))
if (
targetIds.length === 0 &&
existingRecord &&
!existingTargetsNotice &&
!targetlessNoticeIds.has(noticeId)
) {
targetIds = existingRecord.targetIds
}
if (targetIds.length === 0) {
const claimedTargetIds = new Set(bucket.recalls.flatMap((record) => record.targetIds))
const inferred = inferRecallTarget(
byIdentity.values(),
sourceMessage,
recallData.actor,
claimedTargetIds
)
if (inferred) targetIds = messageTargetIds(inferred)
}
if (targetIds.length > 0) {
const nextRecord: RecallRecord = {
targetIds,
actor: recallData.actor,
noticeId,
createTime: sourceMessage.createTime || 0
}
const unchanged =
existingRecord &&
existingRecord.targetIds.length === targetIds.length &&
existingRecord.targetIds.every((targetId) => targetIds.includes(targetId)) &&
existingRecord.actor === nextRecord.actor
if (!unchanged) {
if (existingRecordIndex >= 0) bucket.recalls.splice(existingRecordIndex, 1, nextRecord)
else bucket.recalls.push(nextRecord)
changed = true
}
}
}
const annotated = annotateFromRecall(sourceMessage, bucket.recalls)
const next: ArchivedMessage = {
...annotated,
archivedAt: Date.now(),
recallNotice: isRecallNotice(sourceMessage)
}
const identity = messageIdentity(next)
const previous = byIdentity.get(identity)
if (
!previous ||
previous.recalled !== next.recalled ||
previous.content !== next.content ||
previous.serverId !== next.serverId
) {
byIdentity.set(identity, next)
changed = true
}
}
const recalls = bucket.recalls.slice(-MAX_RECALLS_PER_SESSION)
const nextMessages = Array.from(byIdentity.values())
.map((message) => annotateFromRecall(message, recalls) as ArchivedMessage)
.sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
})
.slice(-MAX_MESSAGES_PER_SESSION)
if (!changed && nextMessages.every((message, index) => message === bucket.messages[index])) return
bucket.messages = nextMessages
bucket.recalls = recalls
bucket.updatedAt = Date.now()
archive.sessions[sessionMd5] = bucket
archive.updatedAt = Date.now()
scheduleWrite()
}
export function mergeRecallArchiveMessages<T extends Message>(
sessionMd5: string,
sourceMessages: T[],
startTime?: number,
endTime?: number,
limit?: number
): T[] {
const bucket = archive?.sessions[sessionMd5]
if (!bucket) return sourceMessages
const merged = new Map<string, Message>()
for (const message of sourceMessages) {
const archived = bucket.messages.find(
(candidate) => messageIdentity(candidate) === messageIdentity(message)
)
merged.set(
messageIdentity(message),
annotateFromRecall(
archived?.recalled
? { ...message, recalled: true, recalledBy: archived.recalledBy }
: message,
bucket.recalls
)
)
}
for (const archived of bucket.messages) {
if (!archived.recalled && !archived.recallNotice) continue
const createTime = archived.createTime || 0
if (startTime && createTime < startTime) continue
if (endTime && createTime > endTime) continue
const identity = messageIdentity(archived)
if (!merged.has(identity)) merged.set(identity, archived)
}
const result = Array.from(merged.values()).sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
})
const visible = limit && result.length > limit ? result.slice(-limit) : result
return visible as T[]
}
export function extractChangedConversationMd5(payload: string): string[] {
const matches = String(payload || '').match(/(?:Chat_|chat_)?([a-f0-9]{32})/gi) || []
return Array.from(
new Set(
matches
.map((value) => value.replace(/^chat_/i, '').toLowerCase())
.filter((value) => /^[a-f0-9]{32}$/.test(value))
)
)
}
type ArchiveContact = {
md5: string
m_nsUsrName: string
type: 'user' | 'group'
activityKey?: string
}
export class RecallArchiveMonitor {
private pending = new Set<string>()
private activityBySession = new Map<string, string>()
private timer: NodeJS.Timeout | null = null
private stopped = false
constructor(
private readonly listContacts: () => ArchiveContact[],
private readonly loadMessages: (sessionMd5: string) => Message[]
) {}
seedAll(): void {
const groups = this.conversationContacts()
this.activityBySession = new Map(
groups.map((contact) => [contact.md5, String(contact.activityKey || '')])
)
const allowed = new Set(groups.map((contact) => contact.md5))
if (archive) {
let pruned = false
for (const sessionMd5 of Object.keys(archive.sessions)) {
if (allowed.has(sessionMd5)) continue
delete archive.sessions[sessionMd5]
pruned = true
}
if (pruned) {
archive.updatedAt = Date.now()
scheduleWrite()
}
}
}
handleDatabaseChange(payload: string): void {
// Ordinary message inserts are already visible in the main reader. The
// archive scan is only needed when the native event indicates a recall.
if (!/(recall|revoke|revokemsg|撤回)/i.test(String(payload || ''))) return
const groups = this.conversationContacts()
const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase()))
const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5))
const changedActivity = groups
.filter((contact) => {
const nextKey = String(contact.activityKey || '')
const previousKey = this.activityBySession.get(contact.md5)
this.activityBySession.set(contact.md5, nextKey)
return Boolean(nextKey && previousKey !== undefined && previousKey !== nextKey)
})
.map((contact) => contact.md5)
const targets = Array.from(new Set([...payloadTargets, ...changedActivity]))
if (targets.length > 0) {
console.log(`[RecallArchive] database change queued=${targets.length}`)
this.enqueue(targets)
}
}
private conversationContacts(): ArchiveContact[] {
return this.listContacts().filter(
(contact) =>
contact.m_nsUsrName &&
!contact.m_nsUsrName.startsWith('@placeholder') &&
contact.m_nsUsrName !== 'brandsessionholder' &&
contact.m_nsUsrName !== 'brandservicesessionholder'
)
}
stop(): void {
this.stopped = true
this.pending.clear()
if (this.timer) clearTimeout(this.timer)
this.timer = null
}
private enqueue(sessionMd5s: string[]): void {
if (this.stopped) return
for (const md5 of sessionMd5s) {
if (md5) this.pending.add(md5)
}
this.schedule()
}
private schedule(): void {
if (this.stopped || this.timer || this.pending.size === 0) return
this.timer = setTimeout(() => {
this.timer = null
const md5 = this.pending.values().next().value as string | undefined
if (!md5) return
this.pending.delete(md5)
try {
this.loadMessages(md5)
} catch (error) {
console.warn(`[RecallArchive] background scan failed md5=${md5}:`, error)
}
this.schedule()
}, 20)
}
}
+73 -40
View File
@@ -3,6 +3,22 @@ import fs from 'fs-extra'
import path from 'path'
import os from 'os'
/**
* V3 "...\\Documents\\WeChat Files"
* "...\\Documents\\xwechat_files"V4 xwechat_files
* WeChat 4.0
*/
function redirectLegacyWeChatFilesToXwechat(candidate: string): string {
if (!candidate) return candidate
const normalized = candidate.replace(/[\\/]+$/, '')
const lowered = normalized.toLowerCase()
const legacyMarker = `${path.sep}wechat files`
if (!lowered.endsWith(legacyMarker)) return candidate
const redirected = `${normalized.slice(0, -legacyMarker.length)}${path.sep}xwechat_files`
if (fs.existsSync(redirected)) return redirected
return candidate
}
export interface AppSettings {
dbRoot: string
apiEnabled: boolean
@@ -12,6 +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 {
@@ -22,28 +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')
]
for (const drive of getWindowsDrives()) {
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
for (const child of listDirectories(`${drive}:\\`)) {
candidates.push(path.join(child, 'xwechat_files'))
candidates.push(path.join(child, 'WeChat Files'))
}
}
return unique(candidates)
}
@@ -66,37 +82,11 @@ function getWeflowDbPathCandidates(home: string): string[] {
return candidates
}
function getWindowsDrives(): string[] {
const drives: string[] = []
for (let code = 67; code <= 90; code += 1) {
const drive = String.fromCharCode(code)
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
}
return drives
}
function listDirectories(root: string): string[] {
try {
return fs
.readdirSync(root)
.map((name) => path.join(root, name))
.filter((candidate) => {
try {
return fs.statSync(candidate).isDirectory()
} catch {
return false
}
})
} catch {
return []
}
}
function unique(values: string[]): string[] {
return Array.from(new Set(values))
}
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 {
@@ -108,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 = {
@@ -118,7 +123,19 @@ const DEFAULT_SETTINGS: AppSettings = {
imageKeyRoot: defaultDbRoot,
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false
imageKeyFallbackDisabled: false,
ffmpegPath: '',
recallProtectionEnabled: false,
debugEnabled: false,
autoLogin: ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
.toLowerCase()
),
autoLoginPreferenceSet: false,
appearanceTheme: 'system',
compactMode: false,
showStartupProgress: true
}
const SETTINGS_FILE = path.join(
@@ -138,12 +155,28 @@ export function loadSettings(): AppSettings {
if (fs.existsSync(SETTINGS_FILE)) {
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
cache = { ...DEFAULT_SETTINGS, ...raw }
if (raw.autoLogin === undefined) {
const hasSavedDatabaseKey = fs.existsSync(
path.join(app.getPath('userData'), 'wechat-db-key.bin')
)
if (hasSavedDatabaseKey) cache.autoLogin = true
}
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
cache.dbRoot = getDefaultDbRoot()
}
if (!cache.imageKeyRoot) {
// 同步:imageKeyRoot 必须跟随 dbRoot 更新,
// 否则自动获取会扫错目录(旧 bug:状态面板显示 D 盘,自动获取扫 C 盘)。
if (!cache.imageKeyRoot || !isUsableDbRoot(cache.imageKeyRoot)) {
cache.imageKeyRoot = cache.dbRoot
}
// V4-only 兜底:如果 imageKeyRoot 指向旧的 "WeChat Files"V3 路径),
// 重定向到同一父目录下的 xwechat_filesV4)。
if (cache.imageKeyRoot) {
cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot)
}
if (cache.dbRoot) {
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
}
return cache
}
} catch (error) {
+2 -1
View File
@@ -1,6 +1,7 @@
import { app, shell } from 'electron'
import { existsSync, promises as fs } from 'fs'
import { dirname, join } from 'path'
import { isPackagedRuntime } from '../runtime-mode'
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
const GITHUB_URL =
@@ -23,7 +24,7 @@ function getSkillCandidates(): { path: string; source: 'development' | 'bundled'
join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH),
join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH)
]
return app.isPackaged
return isPackagedRuntime()
? bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
: [
{ path: developmentPath, source: 'development' as const },
+48 -16
View File
@@ -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',
+126
View File
@@ -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 -1
View File
@@ -2,6 +2,7 @@ import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { Wcdb4Client } from './wcdb4-client'
import { isPackagedRuntime } from './runtime-mode'
export class VoiceService {
private wcdb4Client: Wcdb4Client
@@ -101,7 +102,7 @@ export class VoiceService {
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
try {
let wasmPath: string
if (app.isPackaged) {
if (isPackagedRuntime()) {
wasmPath = join(
process.resourcesPath,
'app.asar.unpacked',
+988 -94
View File
File diff suppressed because it is too large Load Diff
+43 -19
View File
@@ -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
+77
View File
@@ -0,0 +1,77 @@
import fs from 'fs-extra'
import path from 'path'
// 仅支持 WeChat 4.0:只扫描 xwechat_files;旧 V3 时代的 "WeChat Files" 不再纳入候选
const DB_ROOT_NAMES = new Set(['xwechat_files'])
const SKIPPED_DIRECTORY_NAMES = new Set([
'$recycle.bin',
'system volume information',
'windows',
'program files',
'program files (x86)',
'programdata',
'recovery'
])
const MAX_VISITED_DIRECTORIES_PER_DRIVE = 20_000
let cachedDiscoveredRoots: string[] | null = null
function unique(values: string[]): string[] {
return Array.from(new Set(values.map((value) => path.normalize(value))))
}
export function getWindowsDrives(): string[] {
if (process.platform !== 'win32') return []
const drives: string[] = []
for (let code = 67; code <= 90; code += 1) {
const root = `${String.fromCharCode(code)}:\\`
if (fs.existsSync(root)) drives.push(root)
}
return drives
}
export function scanWindowsDbRoots(driveRoots: string[], maxDepth = 3): string[] {
const results: string[] = []
for (const driveRoot of driveRoots) {
const queue: Array<{ directory: string; depth: number }> = [{ directory: driveRoot, depth: 0 }]
let visited = 0
while (queue.length > 0 && visited < MAX_VISITED_DIRECTORIES_PER_DRIVE) {
const current = queue.shift()
if (!current || current.depth >= maxDepth) continue
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(current.directory, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
const lowered = entry.name.toLowerCase()
const fullPath = path.join(current.directory, entry.name)
const depth = current.depth + 1
visited += 1
if (DB_ROOT_NAMES.has(lowered)) {
results.push(fullPath)
continue
}
if (depth < maxDepth && !SKIPPED_DIRECTORY_NAMES.has(lowered)) {
queue.push({ directory: fullPath, depth })
}
if (visited >= MAX_VISITED_DIRECTORIES_PER_DRIVE) break
}
}
}
return unique(results)
}
export function discoverWindowsDbRoots(): string[] {
if (!cachedDiscoveredRoots) {
cachedDiscoveredRoots = scanWindowsDbRoots(getWindowsDrives(), 3)
}
return [...cachedDiscoveredRoots]
}
+177 -12
View File
@@ -11,9 +11,12 @@ import {
import type {
DatabaseKeyEnvironment,
DatabaseKeyStorageResult,
DatabaseKeyValidationResult
DatabaseKeyValidationResult,
AccountDiscoveryResult
} from '../shared/database-key'
import type {
ImageDecoderSelectionResult,
ImageDecoderStatus,
ImageDecryptionStatus,
ImageDecryptionTestResult,
ImageKeyConfigResult,
@@ -30,15 +33,54 @@ import type {
AIVisionTestResult,
LegacyAIConfig
} from '../shared/ai-provider'
import type {
ImageAnalysisRequest,
ImageAnalysisResponse,
ImageCandidate,
ImageCandidateQuery,
ImageInsight
} from '../shared/image-insight'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update'
import type { CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
export type ParsedContent =
| { type: 'text'; content: string }
| { type: 'voice'; duration?: number }
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
| {
type: 'share'
title: string
des?: string
url: string
appname?: string
typeVal?: string
}
| {
type: 'miniProgram'
title: string
description?: string
appName?: string
iconUrl?: string
thumbMd5?: string
thumbDatName?: string
thumbDataUrl?: string
}
| { type: 'redPacket'; title: string; description?: string; url?: string }
| { type: 'voip'; duration?: number; status: string; roomType?: number }
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
| {
type: 'video'
md5?: string
newMd5?: string
rawMd5?: string
duration?: number
width?: number
height?: number
}
| {
type: 'sticker'
md5?: string
@@ -63,14 +105,31 @@ declare global {
interface Window {
electron: ElectronAPI
api: {
writeAppLog: (entry: AppLogEntry) => Promise<void>
getAppLogPath: () => Promise<string>
revealAppLog: () => Promise<void>
getAppUpdateState: () => Promise<AppUpdateState>
checkAppUpdate: () => Promise<AppUpdateCheckResult>
downloadAppUpdate: () => Promise<AppUpdateCheckResult>
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
getCacheSummary: () => Promise<CacheSummary>
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
initDb: (
key: string
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: (
@@ -78,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,
@@ -87,7 +166,14 @@ declare global {
getGroupSnapshot: (userMd5: string) => Promise<{
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
members: {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}[]
} | null>
search: (keyword: string) => Promise<string | null>
aiChat: (
@@ -124,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
@@ -132,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: (
@@ -143,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
@@ -172,20 +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
@@ -196,6 +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
}
@@ -208,6 +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
}>
@@ -218,6 +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
}
@@ -238,7 +377,9 @@ declare global {
}>
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
disconnectDb: () => Promise<{ success: boolean; error?: string }>
disconnectDb: (options?: {
closeNative?: boolean
}) => Promise<{ success: boolean; error?: string }>
apiStatus: () => Promise<{
running: boolean
host: string
@@ -270,6 +411,30 @@ declare global {
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
testLocalApiRequest: (request: LocalApiTestRequest) => Promise<LocalApiTestResponse>
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
// ============================================================
imageListCandidates: (query: ImageCandidateQuery) => Promise<{
success: boolean
candidates: ImageCandidate[]
error?: string
}>
imageAnalyze: (request: ImageAnalysisRequest) => Promise<ImageAnalysisResponse>
getImageInsight: (imageHash: string) => Promise<{ success: boolean; insight?: ImageInsight }>
listImageInsights: (
sessionId: string,
limit?: number
) => Promise<{ success: boolean; insights: ImageInsight[] }>
getAgentHubStatus: () => Promise<AgentHubStatus>
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
clearAgentHubLogs: () => Promise<void>
startAgentHubLogin: () => Promise<AgentHubActionResult>
cancelAgentHubLogin: () => Promise<AgentHubActionResult>
reconnectAgentHub: () => Promise<AgentHubActionResult>
disconnectAgentHub: () => Promise<AgentHubActionResult>
selectAgentHubTestImage: () => Promise<{ canceled: boolean; path?: string }>
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => () => void
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => () => void
}
}
}
+103 -9
View File
@@ -8,15 +8,50 @@ import type {
AIVisionTestRequest,
LegacyAIConfig
} from '../shared/ai-provider'
import type {
ImageAnalysisRequest,
ImageAnalysisResponse,
ImageCandidate,
ImageCandidateQuery,
ImageInsight
} from '../shared/image-insight'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateState } from '../shared/app-update'
import type { CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress } from '../shared/export'
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
import type { AccountDiscoveryResult } from '../shared/database-key'
// 渲染器的自定义 API
const api = {
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
getAppUpdateState: (): Promise<AppUpdateState> => ipcRenderer.invoke('app-update:getState'),
checkAppUpdate: () => ipcRenderer.invoke('app-update:check'),
downloadAppUpdate: () => ipcRenderer.invoke('app-update:download'),
installAppUpdate: () => ipcRenderer.invoke('app-update:install'),
onAppUpdateState: (callback: (state: AppUpdateState) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: AppUpdateState): void =>
callback(state)
ipcRenderer.on('app-update:state', listener)
return () => ipcRenderer.removeListener('app-update:state', listener)
},
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
ipcRenderer.invoke('cache:clear', scope),
initDb: (key: string, 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,
@@ -45,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'),
@@ -56,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,
@@ -98,7 +152,8 @@ const api = {
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
disconnectDb: (options?: { closeNative?: boolean }) =>
ipcRenderer.invoke('db:disconnect', options),
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
apiStop: () => ipcRenderer.invoke('api:stop'),
@@ -108,7 +163,46 @@ const api = {
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request),
copyText: (text: string) => ipcRenderer.invoke('api:copyText', text)
copyText: (text: string) => ipcRenderer.invoke('api:copyText', text),
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
// ============================================================
imageListCandidates: (
query: ImageCandidateQuery
): Promise<{
success: boolean
candidates: ImageCandidate[]
error?: string
}> => ipcRenderer.invoke('image:listCandidates', query),
imageAnalyze: (request: ImageAnalysisRequest): Promise<ImageAnalysisResponse> =>
ipcRenderer.invoke('image:analyze', request),
getImageInsight: (imageHash: string): Promise<{ success: boolean; insight?: ImageInsight }> =>
ipcRenderer.invoke('image:getInsight', imageHash),
listImageInsights: (
sessionId: string,
limit?: number
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
ipcRenderer.invoke('image:listInsights', sessionId, limit),
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
startAgentHubLogin: () => ipcRenderer.invoke('agent-hub:startLogin'),
cancelAgentHubLogin: () => ipcRenderer.invoke('agent-hub:cancelLogin'),
reconnectAgentHub: () => ipcRenderer.invoke('agent-hub:reconnect'),
disconnectAgentHub: () => ipcRenderer.invoke('agent-hub:disconnect'),
selectAgentHubTestImage: () => ipcRenderer.invoke('agent-hub:selectTestImage'),
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: AgentHubStatus): void =>
callback(status)
ipcRenderer.on('agent-hub:status', listener)
return () => ipcRenderer.removeListener('agent-hub:status', listener)
},
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => {
const listener = (_event: Electron.IpcRendererEvent, entry: AgentHubLogEntry): void =>
callback(entry)
ipcRenderer.on('agent-hub:log', listener)
return () => ipcRenderer.removeListener('agent-hub:log', listener)
}
}
if (process.contextIsolated) {
+1 -1
View File
@@ -7,7 +7,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' blob: data: wxe-media:;"
/>
</head>
+892 -258
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+55 -118
View File
@@ -4,72 +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 MAX_RENDERED_MESSAGES = 600
const DATE_RANGE_LABELS: Record<string, string> = {
today: '今天',
yesterday: '昨日',
'7': '7 天',
'30': '30 天',
all: '全部'
}
const formatClock = (date: Date): string =>
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
const formatRangeDate = (date: Date, now: Date): string => {
const clock = formatClock(date)
if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}${date.getDate()}${clock}`
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${clock}`
}
const getChatHeaderRangeLabel = (range: string): string => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
if (range === 'today') return `今天 00:00—现在`
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
if (range === '7') {
const start = new Date(Date.now() - 7 * 86400000)
return `${formatRangeDate(start, now)}—现在`
}
if (range === '30') {
const start = new Date(Date.now() - 30 * 86400000)
return `${formatRangeDate(start, now)}—现在`
}
if (range === 'all') return '全部记录'
return DATE_RANGE_LABELS[range] || '当前范围'
jumpToTime?: number | null
}
const ChatWindow: React.FC<ChatWindowProps> = ({
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')
@@ -86,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' })
@@ -95,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)
@@ -154,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
@@ -172,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 || '')
@@ -242,51 +185,45 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
return typeMatch && contentMatch
})
}, [messages, contentFilter])
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
const renderedMessages = React.useMemo(
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
[filteredMessages]
)
if (!contact) return <EmptyConversationState />
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
return (
<div className="chat-window">
<ChatHeader
contact={contact}
isGroupChat={isGroupChat}
dateRangeLabel={dateRangeLabel}
loadedCount={messages.length}
filteredCount={filteredMessages.length}
contentFilter={contentFilter || ''}
isAiLoading={isAiLoading}
canExport={messages.length > 0}
onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh}
onRefreshData={onRefreshData}
onExport={handleExport}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
/>
<DataTrustBar messageCount={messages.length} />
<MessageList
contact={contact}
messages={renderedMessages}
hiddenMessageCount={hiddenMessageCount}
messages={filteredMessages}
hiddenMessageCount={0}
isLoadingMessages={isLoadingMessages}
messageHistoryStatus={messageHistoryStatus}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
listRef={messageListRef}
bottomRef={messagesEndRef}
onScroll={handleMessageListScroll}
onReachTop={onLoadOlderMessages}
onImageClick={openImagePreview}
jumpToTime={jumpToTime}
/>
<ChatStatusBar
count={renderedMessages.length}
count={filteredMessages.length}
showAvatar={showAvatar}
isAtLatest={isAtLatest}
isReloadingAvatars={isReloadingAvatars}
onShowAvatarChange={setShowAvatar}
onReloadAvatars={() => void handleReloadAvatars()}
onJumpToLatest={scrollToBottom}
/>
@@ -0,0 +1,551 @@
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'
interface DatabaseConnectionPageProps {
platform: string
mode: DatabaseConnectionMode
dbKey: string
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
macKeyFaqUrl: string
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
}
function LineIcon({
name
}: {
name: 'shield' | 'lock' | 'cloud' | 'info' | 'database'
}): React.ReactElement {
const paths = {
shield: <path d="M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z" />,
lock: <path d="M7 10V8a5 5 0 0 1 10 0v2m-11 0h12v10H6V10Z" />,
cloud: (
<path d="m4 4 16 16M7.5 16H6a4 4 0 0 1-.5-8A6.5 6.5 0 0 1 17 6.8M18.5 10A4 4 0 0 1 18 18h-7" />
),
info: <path d="M12 8h.01M11 12h1v4h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" />,
database: (
<path d="M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3Zm0 0v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6m-14 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" />
)
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<g
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
{paths[name]}
</g>
</svg>
)
}
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" />
<circle cx="12" cy="12" r="3" />
{!visible && <path d="M4 4l16 16" />}
</svg>
)
}
function StoragePathHelp(): React.ReactElement {
return (
<span className="database-login-path-help">
<span
className="database-login-path-help-icon"
tabIndex={0}
aria-describedby="storage-path-help"
>
!
</span>
<span id="storage-path-help" className="database-login-path-tooltip" role="tooltip">
</span>
</span>
)
}
export function DatabaseConnectionPage({
platform,
mode,
dbKey,
dbRoot,
showDbKey,
isFetching,
isConnecting,
guideStep,
environment,
accounts = [],
selectedAccountId = '',
status,
statusKind,
showMacKeyFaq,
macKeyFaqUrl,
onModeChange,
onDbKeyChange,
onDbRootChange,
onSelectAccount,
onSelectDbRoot,
onToggleDbKey,
onAutoGetKey,
onRefreshEnvironment,
onGuideNext,
onGuideBack,
onGuideCancel,
onValidateConnection,
onCopyDiagnostics,
onManualConnect,
onPasteKey,
onClearKey
}: DatabaseConnectionPageProps): React.ReactElement {
const isMac = platform === 'darwin'
const defaultPath = isMac
? '~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/'
: 'C:\\Users\\...\\WeChat Files\\Msg'
const keyIsValid = /^[0-9a-f]{64}$/i.test(dbKey.trim().replace(/^0x/i, ''))
return (
<main className="database-login-page">
<section className="database-login-brand" aria-label="WechatExplorer 产品说明">
<div className="database-login-brand-content">
<div className="database-login-logo" aria-hidden="true">
<LineIcon name="database" />
</div>
<h1>WechatExplorer</h1>
<p className="database-login-tagline"> AI </p>
<p className="database-login-description">
使 AI
</p>
<div className="database-login-promises">
<div>
<LineIcon name="shield" />
<span></span>
</div>
<div>
<LineIcon name="lock" />
<span></span>
</div>
<div>
<LineIcon name="cloud" />
<span>AI </span>
</div>
</div>
</div>
<div className="database-login-brand-footer">LOCAL-FIRST · PRIVATE · SECURE</div>
</section>
<section className="database-login-workspace" aria-label="数据库连接">
<div className="database-login-panel">
<div className="database-login-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"
role="tab"
aria-selected={mode === 'automatic'}
className={mode === 'automatic' ? 'active' : ''}
onClick={() => onModeChange('automatic')}
>
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'manual'}
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">
<LineIcon name="info" />
</span>
<div>
<strong>
{statusKind === 'error'
? '当前步骤未完成'
: [
'检查本机环境',
'让微信停在登录页面',
'确认开始准备',
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
'现在可以登录微信',
'验证数据库连接'
][guideStep - 1]}
</strong>
<p>
{statusKind === 'error'
? status
: status ||
[
'确认下方检测结果;没有找到目录时可以手动选择。',
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
'开始后请按页面提示完成系统授权。',
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
'请回到微信完成登录,登录成功后再回来验证。',
'正在验证密钥和本地数据库,请稍候。'
][guideStep - 1]}
</p>
</div>
</div>
{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>
{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">
<input
id="database-login-key"
type={showDbKey ? 'text' : 'password'}
value={dbKey}
onChange={(event) => onDbKeyChange(event.target.value)}
placeholder="输入或粘贴 64 位数据库密钥"
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={onToggleDbKey}
title={showDbKey ? '隐藏密钥' : '显示密钥'}
>
<EyeIcon visible={showDbKey} />
</button>
</div>
<small></small>
</div>
{platform === 'win32' && (
<div className="database-login-field">
<label htmlFor="database-login-root">
<StoragePathHelp />
</label>
<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>}
<button
type="button"
className="database-login-primary"
onClick={onManualConnect}
disabled={!keyIsValid || isConnecting}
>
{isConnecting ? '正在连接…' : '连接数据库'}
</button>
{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>
)}
<div className="database-login-footer-actions">
<button type="button" onClick={onClearKey}>
</button>
<span>WechatExplorer</span>
</div>
</div>
</section>
</main>
)
}
@@ -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>
)
}
+94 -43
View File
@@ -1,11 +1,13 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react'
import { getCachedLoadedImage, requestImage } from './image-loader'
interface ImageBubbleProps {
imageMd5?: string
imageDatName?: string
sessionId?: string
isThumb?: boolean
fallbackUrl?: string
onImageClick?: (imageUrl: string) => void
}
@@ -13,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>
)
}
+100 -112
View File
@@ -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>

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