Merge branch 'feat/local-http-api'

This commit is contained in:
电摇小子
2026-07-09 10:37:20 +08:00
20 changed files with 2501 additions and 279 deletions
-1
View File
@@ -1,7 +1,6 @@
node_modules node_modules
dist dist
out out
docs/
.env .env
.DS_Store .DS_Store
.eslintcache .eslintcache
+68
View File
@@ -83,6 +83,74 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
1. 从 WeFlow/Chatlog 设置中导出 1. 从 WeFlow/Chatlog 设置中导出
2. 使用内存扫描工具从微信进程中自动提取(待实现) 2. 使用内存扫描工具从微信进程中自动提取(待实现)
## 🤖 AI 集成(本地 HTTP API
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
### 启用本地 API
API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要:
1. 安装并启动 WechatExplorer
2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库
3. API 即在 `http://127.0.0.1:6131` 可用
### 7×24 提供 API(菜单栏常驻模式)
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,启用菜单栏模式:
```bash
# 任选一种方式
WXE_TRAY=1 open /Applications/WechatExplorer.app
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
```
启用后:
- macOS dock 图标自动隐藏
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
- 主窗口关闭后 API 服务继续运行
### API 端点一览
| 端点 | 说明 |
|------|------|
| `GET /api/v1/health` | 健康检查 |
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) |
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 |
| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 |
| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 |
| `GET /api/v1/resolve?q=群昵称` | 把昵称/wxid/md5 解析成 md5 |
详细参数、返回结构、时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
### 让 Claude 自动总结你的群聊
复制 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md) 到 `~/.claude/skills/`,然后在 Claude Desktop 里说:
> "今天 技术交流群 聊了啥?"
Claude 会自动:
1.`current_time` 拿到今天日期
2.`chatroom` 找到目标群
3.`chatlog` 拿 JSON 聊天记录
4. 自己用 LLM 生成总结报告
### curl 示例
```bash
# 健康检查
curl http://127.0.0.1:6131/api/v1/health
# 今天 摸鱼交流群 的聊天记录
curl -G "http://127.0.0.1:6131/api/v1/chatlog" \
--data-urlencode "talker=摸鱼交流群" \
--data-urlencode "time=$(date +%Y-%m-%d)"
# 把群昵称解析成 md5
curl -G "http://127.0.0.1:6131/api/v1/resolve" \
--data-urlencode "q=摸鱼交流群"
```
## ⚠️ 免责声明 ## ⚠️ 免责声明
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。 本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
+336
View File
@@ -0,0 +1,336 @@
---
name: wechatexplorer-reader
description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app,而非 chatlog/WeFlow。
---
# WechatExplorer Reader
通过本地 HTTP API(`http://127.0.0.1:6131`)读取 WechatExplorer 已经解锁的微信数据库内容。
## 数据源
- **本服务由 WechatExplorer.app 提供**,数据完全在本地处理,不会上传任何服务器
- 用户必须在 WechatExplorer 主窗口完成**首次密钥配置**(解锁 WCDB 数据库)
- 默认监听 `127.0.0.1:6131`,仅本机可访问,无需鉴权
## 前置条件
1. **安装并启动 WechatExplorer.app**(从项目 release 页面下载)
2. **首次启动时完成密钥配置**:在主界面第一步输入微信数据库密钥(64 位 hex),完成 WCDB 初始化
3. **如需 7×24 提供 API**:用 `WXE_TRAY=1``--tray` 参数启动 app,启用菜单栏常驻模式(主窗口关闭后服务仍在)
## API 列表
GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图)。所有端点返回 JSON。
| 端点 | 用途 | 关键参数 |
|------|------|---------|
| `GET /api/v1/health` | 健康检查 + 是否已初始化 | — |
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) | — |
| `GET /api/v1/contact` | 联系人 / 群聊列表 | `filter`(昵称模糊)、`type`(`user` \| `group`) |
| `GET /api/v1/chatroom` | 群聊列表(等同 contact?type=group) | `keyword` |
| `GET /api/v1/recent_chat` | 最近会话 | `limit`(默认 50) |
| `GET /api/v1/chatlog` | 聊天记录 | `talker``time``startTime`/`endTime` |
| `GET /api/v1/group_snapshot` | 群成员快照 | `md5` |
| `GET /api/v1/resolve` | 把昵称/wxid/md5 解析成 md5 | `q` |
| `POST /api/v1/report` | 生成群聊日报 HTML + 长图 PNG | JSON body(见下文,推荐传 `metadata.talker` 让服务端自动反推真头像) |
### `talker` 参数可接受的值
`chatlog``recent_chat``talker` / 列表项 ID 支持以下三种形式,服务端会按 `nickname → wxid → md5` 顺序匹配:
1. **群昵称 / 好友备注**(模糊匹配,如 `技术交流``摸鱼群`)
2. **微信 wxid**(如 `wxid_abc123``gh_xxxxx@chatroom`)
3. **会话 md5**(如 `49023470180@chatroom` 的 md5 哈希,可在 `contact` 接口里看到)
不确定时先调 `GET /api/v1/resolve?q=<输入>` 校验,返回 `{ md5, m_nsUsrName, m_nsNickName, type, ... }`
### `chatroom` 与 `contact?type=group` 字段一致性
`/chatroom``/contact?type=group` 返回的是**同一个集合**(都是 `listContacts().filter(type==='group')`),字段也完全一致:
```json
{
"m_nsUsrName": "49023470180@chatroom", // wxid, 用作 chatlog 的 talker
"m_nsNickName": { "buffer": "...", "type": "Buffer" }, // nickname 原 buffer
"type": "group",
"md5": "..."
}
```
需要 `displayName` 时从 `m_nsNickName` 里解析;需要拉消息就传 `m_nsUsrName` 当 talker。
## 时间范围格式(`time` 参数)
支持以下格式:
| 输入 | 含义 |
|------|------|
| `2026-07-03` | 单日 00:00:00 ~ 23:59:59 |
| `2026-07-01~2026-07-03` | 日期范围(闭区间) |
| `2026-07-03/14:30` | 单分钟(从 14:30:00 起 60 秒) |
| `2026-07-03/14:30~2026-07-03/15:30` | 精确到分钟的范围 |
也可以直接传 unix 秒级时间戳作为 `startTime``endTime`
### "今天 / 昨天 / 本周" 的时区语义
所有 `time` / `startTime` / `endTime` 都按**用户本机时区**解析(由 `current_time` 里的 `timezone` 字段给出,典型为 `Asia/Shanghai`)。含义如下:
- "今天 2026-07-03" → 本机 2026-07-03 00:00:00 ~ 23:59:59(北京时间 24 小时),**不是** UTC 当天
- "昨天" → 本机昨天 0 点 ~ 23:59:59
- "本周" → 本周一 0 点 ~ 当前时刻(按本机时区所在周的周一)
跨时区时(如用户在国外):仍以本机时区为准,需要按 UTC 处理时显式传 unix 时间戳。
## 时间预检工作流(Time-Aware Workflow)
**重要**:只要用户请求中包含"今天"、"昨天"、"本周"、"刚才"等相对时间概念,**禁止**直接生成日期字符串。
**步骤 1**:先调用 `current_time` 工具获取本地 RFC3339 时间。
**步骤 2**:根据返回的时间计算对应的 `time` 参数。
**步骤 3**:用计算后的参数调 `chatlog`
示例:
- 用户: "今天 摸鱼交流群 聊了啥?"
- AI: 先 `GET /api/v1/current_time` → 得到 `2026-07-03T14:30:00+08:00` → 计算 `time=2026-07-03``GET /api/v1/chatlog?talker=摸鱼交流群&time=2026-07-03`
## 多步上下文检索(强制)
当查询特定话题或特定发送者发言时,**必须**按以下流程操作:
1. **初步定位**:用 `contact``chatroom` 端点确定群聊 md5 / wxid
2. **粗查**:用 `chatlog` + 较宽时间范围找到相关消息时间点
3. **精查**:对每个关键时间点分别查前后 15-30 分钟(不带任何 keyword 过滤),用完整上下文分析
**禁止**:仅凭一次粗查结果直接回答用户。
## 生成群日报(POST /api/v1/report)
当用户希望输出**可视化群日报**(长图 PNG + HTML 邮件版)时,用这个端点。WechatExplorer 内置 `mobile_daily_report.html` 模板,渲染后会同时落盘 `htmlPath``pngPath`,并返回 `imageDataUrl` 可直接预览。
### 请求体(`GroupReportExportRequest`)
```json
{
"report": {
"overview": "一句话总览,20-80 字",
"topics": [
{
"title": "话题标题",
"timeRange": "10:00-12:30",
"heat": "高", // "高" | "中" | "低"
"participants": ["张三", "李四"],
"summary": "本话题讨论了什么",
"conclusion": "可选,达成的结论",
"keywords": ["关键词1", "关键词2"]
}
],
"resources": [
{ "title": "链接/文件标题", "description": "为什么重要", "sender": "张三" }
],
"importantMessages": [
{ "sender": "张三", "time": "10:23", "content": "原消息文本", "note": "为什么重要" }
],
"quotes": [
{
"messages": [{ "sender": "李四", "content": "原话1" }, { "sender": "王五", "content": "原话2" }],
"note": "为什么这些话值得引用"
}
],
"qa": [
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
],
"analytics": {
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
"activeTimeline": "10:00-12:00 为最活跃时段",
"topSpeakers": [{ "name": "张三", "count": 58 }]
},
"keywords": ["高频词1", "高频词2"]
},
"metadata": {
"groupName": "技术交流",
"reportDate": "2026-07-03",
"dateRange": "2026-07-03 全天",
"messageCount": 1234,
"activeUsers": 56,
"timeSpan": "00:00-23:59",
"generatedAt": "2026-07-03 22:00",
"recordNote": "本日报由 WechatExplorer 自动生成",
"footerNote": "底部附加说明",
"heroParticipants": ["张三", "李四"],
"avatars": {},
"talker": "技术交流",
"timeRange": "2026-07-03"
}
}
```
### 响应(`GroupReportExportResult`)
```json
{
"success": true,
"htmlPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.html",
"pngPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.png",
"imageDataUrl": "data:image/png;base64,iVBORw0K..."
}
```
成功返回 200;失败返回 500 + `{ success: false, error: "..." }`。HTML 和 PNG 用 `mobile_daily_report.html` 模板渲染,长图宽度自适应移动端预览。
### 典型工作流
1.`current_time` + `chatlog` 拉取当天/目标时间段消息
2. LLM 总结生成 `report` + `metadata`(直接走 AI 总结即可,无需自己造数据)
3. POST 到 `/api/v1/report` 拿到 `htmlPath` / `pngPath`,把文件路径告诉用户即可在 Finder 打开
4. **不要**自己拼 HTML/PNG,模板已内置,只需组织好 report/metadata 字段
### 必填字段与隐式约束(踩坑提示)
`metadata` 的以下字段**必填**,缺一返回 500:
- `groupName``reportDate``dateRange``generatedAt`
- `heroParticipants`:数组,模板会把每个名字当 key 去 `metadata.avatars[name]` 取头像图
- `avatars`:对象,**每个 `heroParticipants` 里的名字都必须有这个 key**(没有就传 `""`,**不要省略整段**),否则模板渲染会抛 `Cannot read properties of undefined (reading '<名字>')` 报 500
`report` 的以下字段**必须存在**(空就传 `[]`,**不能省略**),否则模板遍历时会抛 `Cannot read properties of undefined (reading 'map')` 报 500:
- `report.topics`(至少 1 个,完全没话题就改用纯文本总结,不要硬生成空日报)
- `report.resources`
- `report.importantMessages`
- `report.quotes`
- `report.qa`
- `report.analytics.topicHeat`
- `report.analytics.topSpeakers`(至少 1 个)
- `report.keywords`
最小安全示例:
```json
{
"report": {
"overview": "...",
"topics": [],
"resources": [],
"importantMessages": [],
"quotes": [],
"qa": [],
"analytics": { "topicHeat": [], "activeTimeline": "", "topSpeakers": [] },
"keywords": []
},
"metadata": {
"groupName": "技术交流",
"reportDate": "2026-07-07",
"dateRange": "2026-07-07 全天",
"heroParticipants": ["张三", "李四"],
"avatars": { "张三": "", "李四": "" }
}
}
```
`report.importantMessages[].time``HH:mm` 格式(不要 ISO 时间戳);`report.analytics.topicHeat[].score` 数字 0-10。
### 4 个数字格子的内容必须紧凑(避免塌陷)
模板顶部的 4 个统计格(`消息数 / 活跃人数 / 时间跨度 / 主要话题`)宽度均分,内容过长会被截断或换行:
| 字段 | 推荐格式 | 反例(会撑爆格子) |
|------|---------|----------------|
| `metadata.messageCount` | 纯数字 `"1234"` | `"约 1.2k 条"` |
| `metadata.activeUsers` | 纯数字 `"56"` | `"大约 50 多人"` |
| `metadata.timeSpan` | **持续时长紧凑半角** `"1 h"` / `"30 min"` / `"2 d"` | `"1 小时"` / `"7 小时"` / `"1天3小时"` |
| `metadata.topicCount` 等 | 数字 / 短中文 | 长句子 |
`timeSpan` 是**首条到末条消息的持续时长**,不是时间区间。**单位用半角空格分隔**:
- `< 1 h``"30 min"`
- `1~24 h``"1 h"` / `"7 h"`(整数,向上取整)
- `> 24 h``"2 d"`(整数,向上取整)
**首末条消息的具体时间点**:`dateRange` 字段会显示完整日期 + 起止时间(无长度限制),模板里 dateRange 是 hero 区的副标题,跟 stat 格子分开。
**区间叙事**(如"主要集中在上午 10 点-12 点")放 `report.analytics.activeTimeline`,那是模板里单独一段的描述,不被 stat 格子限制。
**不传 timeSpan**:服务端会用空字符串渲染(stat 格会空),subagent 应当总是算好时长填进来,或者 renderer 端会自动算(见 renderer 源码)。
### 头像:服务端自动反推(推荐)
**v1.4 起无需手动拼 `avatars` 字典**。在 `metadata` 里加 `talker`(群昵称/wxid/md5 都行),服务端会用 `getGroupSnapshot` 拉全量群成员,按 `nickname → avatar` 自动反推填进 `metadata.avatars`。LLM 总结里出现的 `heroParticipants` / `topics[].participants` / `topSpeakers[].name` 等所有名字都会被覆盖。
**优先级**:客户端传的 `avatars[name]`(非空字符串) > 服务端反推 > 占位 SVG(姓名首字母 + 随机色块)。
**回退**:不传 `talker` 时按 `metadata.avatars` 字典取;还取不到则生成 SVG 占位(`fallbackAvatar`),**不会变空白方块**(v1.4 修了 data URL 正则,SVG 占位能正常嵌入)。
**手动覆盖**:仍可传 `avatars` 字典强制使用自定义头像,例如 `{"张三": "data:image/jpeg;base64,..."}`
**P2 风险**:群里有两人同名(如"杨伟")时,服务端只取首条;客户端可手动覆盖。
## 隐私安全原则
1. **最小化原则**:只返回用户明确请求的内容,不过度展开无关聊天
2. **本地处理**:所有数据来自用户本机,API 不缓存、不转发
3. **摘要优先**:对于大量聊天记录,先提供摘要而非完整 dump
4. **用户确认**:涉及敏感内容时,先展示摘要,让用户决定是否继续深入
## 典型工作流示例
**示例 1:今日群聊总结(纯文本)**
1. `GET /api/v1/current_time` → 获取今天日期
2. `GET /api/v1/chatroom?keyword=技术交流` → 找到目标群 md5
3. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉取今天的聊天
4. AI 用 LLM 生成总结报告(话题 TOP N、最活跃发言者等)
**示例 2:搜索特定消息上下文**
1. `GET /api/v1/chatlog?talker=摸鱼群&time=2026-07-01~2026-07-03` → 粗查近 3 天
2. 在返回的消息中定位关键词出现的时间点 T1, T2, ...
3. 对每个 Ti 分别查 `chatlog?talker=摸鱼群&time=Ti-15min~Ti+15min`,分析上下文
**示例 3:群日报(可视化长图)**
1. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉今天聊天
2. LLM 按上方 `GroupDailyReport` schema 总结出 `report` + `metadata`
3. `POST /api/v1/report` body = 上述 JSON → 拿到 `htmlPath` / `pngPath` / `imageDataUrl`
4.`imageDataUrl` 给用户预览,把 `pngPath` 路径告诉用户用 Finder 打开
## 错误处理
- `503` → WechatExplorer 未初始化(密钥未配置),提示用户在主窗口完成配置
- `404 talker not found` → talker 不存在,先调 `contact``resolve` 确认 md5/wxid
- `400 missing required parameter` → 检查必填参数(talker / md5 / q)
- `200``result.warnings: ['enrich skipped: talker "X" not found']``/report``metadata.talker` 解析失败,头像走 SVG fallback(不阻断生成)
- `200``result.warnings: ['enriched N member avatars from snapshot (M members)']` → enrich 成功(诊断用)
- `400 请求体为空 / 需包含 report 和 metadata` → 调用 `/report` 时 body 必须是非空 JSON,且有这两个顶层字段
- `500 success=false` → 模板渲染失败,通常因 `report` 字段缺失或 `metadata.groupName/reportDate` 为空,检查后重试
## 配置 Claude Desktop
把以下加入 `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"wechatexplorer": {
"command": "npx",
"args": ["-y", "@wechatexplorer/mcp-bridge"]
}
}
}
```
(待 P3 实现 — MCP bridge 包,在此之前可直接用 `curl` 调用 HTTP API,或通过 mcp-remote 桥接。)
## 配置 Claude Code / Codex
`~/.claude/settings.json` 或项目级 `.claude/settings.local.json` 中:
```json
{
"mcpServers": {
"wechatexplorer": {
"url": "http://127.0.0.1:6131"
}
}
}
```
(视 MCP over HTTP 支持情况调整)
+10 -1
View File
@@ -44,6 +44,11 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 14px; gap: 14px;
min-width: 0;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
} }
.hero h1 { .hero h1 {
font-size: 23px; font-size: 23px;
@@ -82,11 +87,15 @@
border-radius: 12px; border-radius: 12px;
padding: 10px 6px; padding: 10px 6px;
text-align: center; text-align: center;
min-width: 0;
overflow: hidden;
} }
.stat b { .stat b {
display: block; display: block;
font-size: 18px; font-size: 18px;
color: #07a352; color: #07a352;
white-space: nowrap;
line-height: 1.25;
} }
.stat span { .stat span {
font-size: 11px; font-size: 11px;
@@ -420,7 +429,7 @@
<div class="stats"> <div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div> <div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</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>{{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> </div>
</header> </header>
+59 -3
View File
@@ -5,8 +5,10 @@ import path from 'path'
import { import {
GroupReportExportRequest, GroupReportExportRequest,
GroupReportExportResult, GroupReportExportResult,
GroupReportMetadata,
ReportHeat ReportHeat
} from '../shared/group-report' } from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
const TEMPLATE_NAME = 'mobile_daily_report.html' const TEMPLATE_NAME = 'mobile_daily_report.html'
@@ -48,7 +50,7 @@ const imageMimeType = (contentType: string | null, source: string): string => {
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => { const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
if (!source) return fallbackAvatar(name) if (!source) return fallbackAvatar(name)
if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
try { try {
if (/^https?:\/\//i.test(source)) { if (/^https?:\/\//i.test(source)) {
@@ -84,6 +86,51 @@ const templatePath = (): string => {
return found return found
} }
/**
* 从群成员快照反推真头像,填进 metadata.avatars。
* - 没传 talker → 跳过(向后兼容)
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
* - 同名取首条(P2 风险:群里两人同名)
*/
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
if (!metadata.talker) return
const resolved = resolveMd5(metadata.talker)
if (!resolved) {
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(`enrich skipped: talker "${metadata.talker}" not found`)
return
}
const snapshot = getGroupSnapshot(resolved.md5)
if (!snapshot) {
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(
`enrich skipped: group snapshot not available for "${metadata.talker}"`
)
return
}
const index = new Map<string, string>()
for (const member of snapshot.members) {
if (member.nickname && member.avatar && !index.has(member.nickname)) {
index.set(member.nickname, member.avatar)
}
}
metadata.avatars = metadata.avatars ?? {}
for (const [name, url] of index) {
if (metadata.avatars[name]) continue
metadata.avatars[name] = url
}
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(
`enriched ${index.size} member avatars from snapshot (${snapshot.memberCount} members)`
)
}
const heatClass = (heat: ReportHeat): string => { const heatClass = (heat: ReportHeat): string => {
if (heat === '高') return 'hot' if (heat === '高') return 'hot'
if (heat === '低') return 'blue' if (heat === '低') return 'blue'
@@ -208,7 +255,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
HERO_AVATARS: heroAvatars, HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(metadata.messageCount), MESSAGE_COUNT: String(metadata.messageCount),
ACTIVE_USERS: String(metadata.activeUsers), ACTIVE_USERS: String(metadata.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan), TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
TOPIC_COUNT: String(report.topics.length), TOPIC_COUNT: String(report.topics.length),
TOPIC_CARDS: topicCards, TOPIC_CARDS: topicCards,
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section', RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
@@ -280,6 +327,9 @@ export const exportGroupReport = async (
request: GroupReportExportRequest request: GroupReportExportRequest
): Promise<GroupReportExportResult> => { ): Promise<GroupReportExportResult> => {
try { try {
// === enrich 在 render 之前:从群成员快照反推真头像 ===
await enrichAvatarsFromGroup(request.metadata)
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录') const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir) await fs.ensureDir(outputDir)
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图` const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
@@ -288,7 +338,13 @@ export const exportGroupReport = async (
const html = await renderReportHtml(request) const html = await renderReportHtml(request)
await fs.writeFile(htmlPath, html, 'utf8') await fs.writeFile(htmlPath, html, 'utf8')
const imageDataUrl = await captureFullPage(htmlPath, pngPath) const imageDataUrl = await captureFullPage(htmlPath, pngPath)
return { success: true, htmlPath, pngPath, imageDataUrl } return {
success: true,
htmlPath,
pngPath,
imageDataUrl,
warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined
}
} catch (error) { } catch (error) {
console.error('[GroupReport] export failed:', error) console.error('[GroupReport] export failed:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) } return { success: false, error: error instanceof Error ? error.message : String(error) }
+382
View File
@@ -0,0 +1,382 @@
import http, { IncomingMessage, ServerResponse, Server } from 'http'
import {
isReady,
listContacts,
listMessages,
getGroupSnapshot,
listRecentChat,
resolveMd5
} from './services/chat-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { safeError, safeLog, safeWarn } from './safe-log'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
export const DEFAULT_HTTP_PORT = 6131
export interface HttpServerHandle {
host: string
port: number
close(): Promise<void>
}
interface RouteContext {
req: IncomingMessage
res: ServerResponse
url: URL
body?: unknown
}
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
const body = JSON.stringify(payload, null, 2)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store'
})
res.end(body)
}
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
req.on('error', reject)
})
}
function rangeToSec(input: string, endOfUnit = false): number | null {
const m = input.match(/^(\d{4})-(\d{2})-(\d{2})(?:\/(\d{2}):(\d{2}))?$/)
if (!m) return null
const [, y, mo, d, hStr, miStr] = m
const hasTime = hStr !== undefined
let hh: number, mi: number, ss: number, ms: number
if (hasTime) {
hh = Number(hStr)
mi = Number(miStr)
ss = endOfUnit ? 59 : 0
ms = endOfUnit ? 999 : 0
} else if (endOfUnit) {
hh = 23
mi = 59
ss = 59
ms = 999
} else {
hh = 0
mi = 0
ss = 0
ms = 0
}
const date = new Date(Number(y), Number(mo) - 1, Number(d), hh, mi, ss, ms)
return Math.floor(date.getTime() / 1000)
}
function parseTimeRange(value: string | null): { startTime?: number; endTime?: number } {
if (!value) return {}
const trimmed = value.trim()
if (!trimmed) return {}
if (/^\d{10,13}$/.test(trimmed)) {
const n = Number(trimmed)
if (!Number.isFinite(n)) return {}
return { startTime: n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) }
}
if (trimmed.includes('~')) {
const [a, b] = trimmed.split('~').map((s) => s.trim())
const start = rangeToSec(a, false)
const end = rangeToSec(b, true)
return {
startTime: start ?? undefined,
endTime: end ?? undefined
}
}
const start = rangeToSec(trimmed, false)
const end = rangeToSec(trimmed, true)
return {
startTime: start ?? undefined,
endTime: end ?? undefined
}
}
function parseNumeric(value: string | null, fallback: number): number {
if (!value) return fallback
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
const routes: Record<string, RouteHandler> = {
'/api/v1/health': ({ res }) => {
sendJson(res, 200, {
ok: true,
ready: isReady(),
service: 'WechatExplorer Reader',
version: '1.0.0',
timestamp: new Date().toISOString()
})
},
'/api/v1/current_time': ({ res }) => {
const now = new Date()
sendJson(res, 200, {
time: now.toISOString(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timestamp: Math.floor(now.getTime() / 1000),
localDate: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
now.getDate()
).padStart(2, '0')}`
})
},
'/api/v1/contact': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const filter = url.searchParams.get('filter') || undefined
const type = url.searchParams.get('type') || undefined
let contacts = listContacts(filter)
if (type === 'user' || type === 'group') {
contacts = contacts.filter((c) => c.type === type)
}
sendJson(res, 200, { count: contacts.length, contacts })
},
'/api/v1/chatroom': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const keyword = url.searchParams.get('keyword') || ''
let groups = listContacts().filter((c) => c.type === 'group')
if (keyword) {
const lower = keyword.toLowerCase()
groups = groups.filter(
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
)
}
sendJson(res, 200, { count: groups.length, chatrooms: groups })
},
'/api/v1/recent_chat': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const limit = parseNumeric(url.searchParams.get('limit'), 50)
const items = listRecentChat(limit)
sendJson(res, 200, { count: items.length, items })
},
'/api/v1/chatlog': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const talker = url.searchParams.get('talker')
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
const resolved = resolveMd5(talker)
if (!resolved) return sendError(res, 404, `未找到会话: ${talker}`)
const timeParam = url.searchParams.get('time')
const startParam = url.searchParams.get('startTime')
const endParam = url.searchParams.get('endTime')
let startTime: number | undefined
let endTime: number | undefined
if (timeParam) {
const range = parseTimeRange(timeParam)
startTime = range.startTime
endTime = range.endTime
} else {
if (startParam) {
const r = parseTimeRange(startParam)
startTime = r.startTime
}
if (endParam) {
const r = parseTimeRange(endParam)
endTime = r.endTime
}
}
const messages = listMessages(resolved.md5, startTime, endTime)
sendJson(res, 200, {
contact: resolved,
query: { talker, time: timeParam, startTime, endTime },
count: messages.length,
messages
})
},
'/api/v1/group_snapshot': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const md5 = url.searchParams.get('md5')
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
const snapshot = getGroupSnapshot(md5)
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
sendJson(res, 200, snapshot)
},
'/api/v1/resolve': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const q = url.searchParams.get('q')
if (!q) return sendError(res, 400, '缺少必要参数 q')
const contact = resolveMd5(q)
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
sendJson(res, 200, contact)
},
'/api/v1/report': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (typeof body !== 'string' || !body.trim()) {
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
}
let request: GroupReportExportRequest
try {
request = JSON.parse(body) as GroupReportExportRequest
} catch (error) {
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
}
if (!request?.report || !request?.metadata) {
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
}
}
export function startHttpServer(
host: string = DEFAULT_HTTP_HOST,
port: number = DEFAULT_HTTP_PORT
): Promise<HttpServerHandle> {
return new Promise((resolve, reject) => {
const server: Server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || '/', `http://${host}:${port}`)
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': '*'
})
return res.end()
}
const handler = routes[url.pathname]
if (!handler) {
return sendError(res, 404, `端点不存在: ${url.pathname}`)
}
let body: string | undefined
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
body = await readBody(req)
}
const ctx: RouteContext = { req, res, url, body }
await handler(ctx)
} catch (error) {
safeError('[HttpServer] 请求处理失败:', error)
if (!res.headersSent) {
sendError(res, 500, error instanceof Error ? error.message : String(error))
}
}
})
server.once('error', (error: NodeJS.ErrnoException) => {
const message =
error.code === 'EADDRINUSE'
? `端口 ${port} 已被占用,请关闭占用进程或在设置中更换端口`
: error.message
reject(Object.assign(error, { friendlyMessage: message }))
})
server.listen(port, host, () => {
server.off('error', () => undefined)
const actualPort = (server.address() as { port: number } | null)?.port ?? port
safeLog(`[HttpServer] Listening on http://${host}:${actualPort}`)
resolve({
host,
port: actualPort,
close: () =>
new Promise<void>((res) => {
server.close(() => res())
})
})
})
})
}
export interface ApiServerState {
running: boolean
host: string
port: number
error?: string
}
let singleton: HttpServerHandle | null = null
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export const apiServer = {
isRunning(): boolean {
return singleton !== null
},
getState(): ApiServerState {
return { ...singletonState }
},
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
if (singleton) {
return this.getState()
}
const maxAttempts = 4
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
singleton = await startHttpServer(host, port)
singletonState = {
running: true,
host: singleton.host,
port: singleton.port
}
safeLog(`[ApiServer] started on http://${singleton.host}:${singleton.port}`)
return { ...singletonState }
} catch (error) {
lastError = error as NodeJS.ErrnoException & { friendlyMessage?: string }
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE' || attempt === maxAttempts) break
// Brief wait to let the OS release the port (TIME_WAIT / concurrent dev session).
await sleep(400 * attempt)
}
}
const message =
lastError?.friendlyMessage ||
(lastError instanceof Error ? lastError.message : String(lastError)) ||
'API 启动失败'
singletonState = {
running: false,
host,
port,
error: message
}
safeError('[ApiServer] start failed:', message)
return { ...singletonState }
},
async stop(): Promise<ApiServerState> {
if (!singleton) {
return this.getState()
}
try {
await singleton.close()
} catch (error) {
safeWarn('[ApiServer] close failed:', error)
}
singleton = null
singletonState = { ...singletonState, running: false }
safeLog('[ApiServer] stopped')
return { ...singletonState }
}
}
+153 -244
View File
@@ -1,61 +1,47 @@
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard } from 'electron' import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard, Menu, Tray } from 'electron'
import { join } from 'path' import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { WechatDb, Contact, WechatMessage } from './wechat-db' import { WechatDb } from './wechat-db'
import { VoiceService } from './voice-service' import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service' import { StickerService } from './sticker-service'
import { import { parseMessageContent } from './message-parser'
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
} from './message-parser'
import { ImageDecryptService } from './image-decrypt-service' import { ImageDecryptService } from './image-decrypt-service'
import { exportGroupReport } from './group-report-service' import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report' import { GroupReportExportRequest } from '../shared/group-report'
import { DatabaseKeyStore } from './database-key-store' import { DatabaseKeyStore } from './database-key-store'
import { KeyServiceMac } from './key-service-mac' import { KeyServiceMac } from './key-service-mac'
import * as chat from './services/chat-service'
import {
apiServer
} from './http-server'
import {
loadSettings,
saveSettings,
getSettingsPath,
AppSettings
} from './services/settings-store'
import { installSafeConsole } from './safe-log'
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
// handler. Wrap console.* before any other module logs anything.
installSafeConsole()
let wechatDb: WechatDb | null = null
let voiceService: VoiceService | null = null let voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null let stickerService: StickerService | null = null
const databaseKeyStore = new DatabaseKeyStore() const databaseKeyStore = new DatabaseKeyStore()
const keyServiceMac = new KeyServiceMac() const keyServiceMac = new KeyServiceMac()
const BUILD_MARK = 'wechat4-open-account-continues-after-init-1000' let tray: Tray | null = null
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
const TRAY_MODE =
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init. // WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
// In dev, matching the host app name avoids failing the native protection gate. // In dev, matching the host app name avoids failing the native protection gate.
app.setName('WechatExplorer') app.setName('WechatExplorer')
const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
50: '通话',
10000: '系统消息'
}
function normalizeMsgType(value: string | number | undefined): number {
const raw = String(value ?? '').trim()
if (!raw) return 0
try {
const parsed = BigInt(raw)
const low32 = Number(parsed & 0xffffffffn)
return low32 || Number(parsed)
} catch {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return parsed > 0xffffffff ? parsed >>> 0 : parsed
}
}
function createWindow(): void { function createWindow(): void {
// 创建浏览器窗口 // 创建浏览器窗口
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
@@ -90,7 +76,7 @@ function createWindow(): void {
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法 // 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
// 某些 API 只能在此事件发生后使用 // 某些 API 只能在此事件发生后使用
app.whenReady().then(() => { app.whenReady().then(async () => {
console.log(`WechatExplorer main build: ${BUILD_MARK}`) console.log(`WechatExplorer main build: ${BUILD_MARK}`)
// 为窗口设置应用程序用户模型 ID // 为窗口设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.electron') electronApp.setAppUserModelId('com.electron')
@@ -110,8 +96,7 @@ app.whenReady().then(() => {
const trimmedKey = String(key || '').trim() const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`) console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const nextWechatDb = new WechatDb(key) const nextWechatDb = new WechatDb(key)
wechatDb?.close() chat.setChatDb(nextWechatDb)
wechatDb = nextWechatDb
const wcdb4Client = nextWechatDb.getWcdb4Client() const wcdb4Client = nextWechatDb.getWcdb4Client()
voiceService = new VoiceService(wcdb4Client) voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client) stickerService = new StickerService(wcdb4Client)
@@ -135,6 +120,8 @@ app.whenReady().then(() => {
return databaseKeyStore.save(clipboardKey) return databaseKeyStore.save(clipboardKey)
}) })
ipcMain.handle('key:saveDbKey', async (_, key: string) => databaseKeyStore.save(String(key || '')))
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear()) ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
ipcMain.handle('key:autoGetDbKey', async (event) => { ipcMain.handle('key:autoGetDbKey', async (event) => {
@@ -151,208 +138,15 @@ app.whenReady().then(() => {
} }
}) })
ipcMain.handle('db:getContacts', (_, filter?: string) => { ipcMain.handle('db:getContacts', (_, filter?: string) => chat.listContacts(filter))
if (!wechatDb) return []
const contacts: Contact[] = [] ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) =>
const groupContacts = wechatDb.getAllGroupContacts() chat.listMessages(userMd5, startTime, endTime)
const userList = wechatDb.getUserList(filter) )
const existingMd5s = new Set<string>()
// 1. 处理普通联系人 ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
for (const user of userList) {
const md5 = wechatDb.md5(user.m_nsUsrName)
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
existingMd5s.add(md5)
contacts.push({
m_nsUsrName: user.m_nsUsrName,
m_nsNickName: user.nickname || '未知用户',
md5: md5,
type: isGroup ? 'group' : 'user',
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
})
}
// 2. 处理聊天表 ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
const chatTables = wechatDb.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
if (!existingMd5s.has(md5)) {
if (groupContacts[md5]) {
contacts.push({
m_nsUsrName: `Group_${md5}`,
m_nsNickName: groupContacts[md5],
md5: md5,
type: 'group'
})
} else {
contacts.push({
m_nsUsrName: `Unknown_${md5}`,
m_nsNickName: `Chat_${md5}`,
md5: md5,
type: 'user'
})
}
}
}
return contacts
})
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) => {
if (!wechatDb) return []
const wcdb4Client = wechatDb.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
const groupMembers = wechatDb.getGroupMembersForChat(userMd5)
const myAvatar = wechatDb.getMyAvatarUrl()
const myGroupNickname = username?.endsWith('@chatroom')
? wcdb4Client.getMyGroupNickname(username)
: undefined
return rawMessages.map((msg: WechatMessage) => {
const rawMsgType = parseInt(msg.messageType)
const msgType = normalizeMsgType(msg.messageType)
const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000)
const isMine = msg.mesDes !== 1
const localId = parseInt(msg.mesLocalID) || 0
let content = msg.msgContent
let img = ''
let name = ''
if (isMine) {
if (myAvatar) img = myAvatar
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
} else {
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
}
// 检查内容是否以 wxid 开头并包含冒号
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
if (content && typeof content === 'string') {
const colonIndex = content.indexOf(':')
if (colonIndex > 0) {
const potentialWxid = content.substring(0, colonIndex)
if (potentialWxid.startsWith('wxid_')) {
// 尝试获取头像
if (wechatDb) {
const member = wechatDb.getGroupMember(potentialWxid)
if (member) {
img = member.m_nsHeadImgUrl
}
}
if (groupMembers[potentialWxid]) {
const nickname = groupMembers[potentialWxid]
name = nickname
content = content.substring(colonIndex + 1) // +1 to skip the colon
}
}
}
}
// 解析富媒体消息内容
let contentData: ReturnType<typeof parseMessageContent> | undefined = undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const inferredMsgType =
typeof content === 'string' &&
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const parsed =
inferredMsgType === 47
? parseStickerMessageFromRow(msg, content)
: parseMessageContent(content, inferredMsgType)
if (parsed.type === 'system') {
content = parsed.content
contentData = parsed
} else if (parsed.type !== 'unknown') {
content = ''
}
if (parsed.type === 'image') {
const imageDatName = parseImageDatNameFromRow(msg)
contentData = { ...parsed, datName: parsed.datName || imageDatName }
} else if (parsed.type !== 'system') {
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
contentData = parsed
}
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
}
} catch {
// ignore parse errors
}
}
if (
!contentData &&
typeof content === 'string' &&
/^[0-9a-fA-F]{64,}$/.test(content.trim())
) {
const parsed = parseStickerMessageFromRow(msg, content)
if (parsed.type === 'sticker') {
if (!parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
content = ''
contentData = parsed
displayType = '表情包'
}
}
if (msgType === 34) {
content = '[语音消息]'
}
return {
id: msg.mesLocalID || Math.random().toString(),
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
type: displayType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
content: content,
img: img,
name: name,
sessionId: username,
localId: localId,
createTime: createTime,
contentData: contentData
}
})
})
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => {
if (!wechatDb) return null
const wcdb4Client = wechatDb.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = wcdb4Client
.getGroupMembers(roomId)
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
avatar: member.m_nsHeadImgUrl || ''
}))
return {
roomId,
memberCount: members.length,
members
}
})
ipcMain.handle('db:search', (_, keyword: string) => {
if (!wechatDb) return null
return wechatDb.searchAllMessages(keyword)
})
ipcMain.handle( ipcMain.handle(
'ai:chat', 'ai:chat',
@@ -441,7 +235,7 @@ app.whenReady().then(() => {
if (!aesKey) { if (!aesKey) {
return { success: false, error: '未配置图片解密密钥' } return { success: false, error: '未配置图片解密密钥' }
} }
imageDecryptService = new ImageDecryptService(xorKey, aesKey, wechatDb?.getWcdb4Client()) imageDecryptService = new ImageDecryptService(xorKey, aesKey, chat.getChatDb()?.getWcdb4Client())
} }
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
@@ -461,13 +255,75 @@ app.whenReady().then(() => {
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => { ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
if (!stickerService) { if (!stickerService) {
stickerService = new StickerService(wechatDb?.getWcdb4Client()) stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
} }
return stickerService.resolveSticker(cdnUrl, md5) return stickerService.resolveSticker(cdnUrl, md5)
}) })
// -------- Settings & API service --------
ipcMain.handle('settings:get', () => ({
settings: loadSettings(),
settingsPath: getSettingsPath()
}))
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
const merged = saveSettings({ ...loadSettings(), ...patch })
return { settings: merged, settingsPath: getSettingsPath() }
})
ipcMain.handle('settings:getSelf', () => {
const info = chat.getSelfAccountInfo()
if (!info) return { ready: false }
return { ready: true, info }
})
ipcMain.handle('db:testConnection', (_, key: string, accountRoot?: string) => {
return chat.testConnection(key, accountRoot)
})
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
const ok = chat.reopenWithRoot(accountRoot)
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
const info = chat.getSelfAccountInfo()
return { success: true, info }
})
ipcMain.handle('api:getStatus', () => apiServer.getState())
ipcMain.handle('api:start', async (_, host?: string, port?: number) => {
const settings = loadSettings()
const target = {
host: host || settings.apiHost,
port: port || settings.apiPort
}
if (host || port) saveSettings({ ...settings, ...target })
return apiServer.start(target.host, target.port)
})
ipcMain.handle('api:stop', async () => apiServer.stop())
ipcMain.handle('api:toggle', async (_, enabled: boolean) => {
const settings = saveSettings({ ...loadSettings(), apiEnabled: enabled })
if (enabled) {
return apiServer.start(settings.apiHost, settings.apiPort)
}
return apiServer.stop()
})
createWindow() createWindow()
// 启动本地 HTTP API(根据 settings.apiEnabled 控制)
const settings = loadSettings()
if (settings.apiEnabled) {
await apiServer.start(settings.apiHost, settings.apiPort)
}
if (TRAY_MODE) {
app.dock?.hide()
setupTray()
}
app.on('activate', function () { app.on('activate', function () {
// 在 macOS 上,当点击 dock 图标且没有其他窗口打开时, // 在 macOS 上,当点击 dock 图标且没有其他窗口打开时,
// 通常会在应用程序中重新创建一个窗口。 // 通常会在应用程序中重新创建一个窗口。
@@ -479,12 +335,65 @@ app.whenReady().then(() => {
// 应用程序及其菜单栏通常会保持活动状态,直到用户 // 应用程序及其菜单栏通常会保持活动状态,直到用户
// 显式使用 Cmd + Q 退出。 // 显式使用 Cmd + Q 退出。
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (TRAY_MODE) return
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit() app.quit()
} }
}) })
app.on('before-quit', () => { app.on('before-quit', async () => {
wechatDb?.close() chat.setChatDb(null)
wechatDb = null await apiServer.stop().catch(() => undefined)
if (tray) {
tray.destroy()
tray = null
}
}) })
function showMainWindow(): void {
if (TRAY_MODE) app.dock?.show().catch(() => undefined)
const wins = BrowserWindow.getAllWindows()
if (wins.length === 0) {
createWindow()
return
}
const win = wins[0]
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
function buildTrayMenu(): Menu {
return Menu.buildFromTemplate([
{
label: '打开主窗口',
click: () => showMainWindow()
},
{
label: 'API 状态',
click: () => showMainWindow()
},
{ type: 'separator' },
{
label: '退出 WechatExplorer',
click: () => {
tray?.destroy()
tray = null
app.quit()
}
}
])
}
function setupTray(): void {
if (tray) return
try {
const image = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image)
tray.setToolTip('WechatExplorer')
tray.setContextMenu(buildTrayMenu())
tray.on('click', () => showMainWindow())
} catch (error) {
console.warn('[Tray] Failed to create tray:', error)
}
}
+25
View File
@@ -0,0 +1,25 @@
// Safe logging for Electron child processes whose stdout/stderr pipes can be
// closed by the parent (electron-vite). Plain console.error throws EPIPE
// against a closed pipe, which crashes the IPC handler. Wrap writes so any
// pipe error is swallowed.
type SafeConsoleMethod = (...args: unknown[]) => void
function makeSafe(method: SafeConsoleMethod): SafeConsoleMethod {
return (...args: unknown[]) => {
try {
method(...args)
} catch {
// Swallow EPIPE / ERR_STREAM_DESTROYED; logging must never crash the app.
}
}
}
export const safeLog = makeSafe(console.log.bind(console))
export const safeWarn = makeSafe(console.warn.bind(console))
export const safeError = makeSafe(console.error.bind(console))
export function installSafeConsole(): void {
console.log = safeLog
console.warn = safeWarn
console.error = safeError
}
+393
View File
@@ -0,0 +1,393 @@
import { WechatDb, WechatMessage } from '../wechat-db'
import {
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
} from '../message-parser'
export function getCurrentKey(): string {
if (!dbRef) return ''
try {
return dbRef.getWcdb4Client().getKey()
} catch {
return ''
}
}
export interface FormattedContact {
m_nsUsrName: string
m_nsNickName: string
md5: string
type: 'user' | 'group'
avatar?: string
}
export interface FormattedMessage {
id: string
from: string
type: string
datetime: string
content: string
isSender: boolean
img?: string
name?: string
contentData?: ReturnType<typeof parseMessageContent>
voiceDataUrl?: string
voiceDuration?: number
localId?: number
createTime?: number
sessionId?: string
}
export interface GroupSnapshot {
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
}
const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
50: '通话',
10000: '系统消息'
}
function normalizeMsgType(value: string | number | undefined): number {
const raw = String(value ?? '').trim()
if (!raw) return 0
try {
const parsed = BigInt(raw)
const low32 = Number(parsed & 0xffffffffn)
return low32 || Number(parsed)
} catch {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return parsed > 0xffffffff ? parsed >>> 0 : parsed
}
}
let dbRef: WechatDb | null = null
export function setChatDb(db: WechatDb | null): void {
dbRef?.close()
dbRef = db
}
export function getChatDb(): WechatDb | null {
return dbRef
}
export function isReady(): boolean {
return dbRef !== null
}
export function listContacts(filter?: string): FormattedContact[] {
if (!dbRef) return []
const contacts: FormattedContact[] = []
const groupContacts = dbRef.getAllGroupContacts()
const userList = dbRef.getUserList(filter)
const existingMd5s = new Set<string>()
for (const user of userList) {
const md5 = dbRef.md5(user.m_nsUsrName)
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
existingMd5s.add(md5)
contacts.push({
m_nsUsrName: user.m_nsUsrName,
m_nsNickName: user.nickname || '未知用户',
md5,
type: isGroup ? 'group' : 'user',
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
})
}
const chatTables = dbRef.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
if (existingMd5s.has(md5)) continue
if (groupContacts[md5]) {
contacts.push({
m_nsUsrName: `Group_${md5}`,
m_nsNickName: groupContacts[md5],
md5,
type: 'group'
})
} else {
contacts.push({
m_nsUsrName: `Unknown_${md5}`,
m_nsNickName: `Chat_${md5}`,
md5,
type: 'user'
})
}
}
return contacts
}
export function listMessages(
userMd5: string,
startTime?: number,
endTime?: number
): FormattedMessage[] {
if (!dbRef) return []
const wcdb4Client = dbRef.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime)
const groupMembers = dbRef.getGroupMembersForChat(userMd5)
const myAvatar = dbRef.getMyAvatarUrl()
const myGroupNickname = username?.endsWith('@chatroom')
? wcdb4Client.getMyGroupNickname(username)
: undefined
return rawMessages.map((msg: WechatMessage) => {
const rawMsgType = parseInt(msg.messageType)
const msgType = normalizeMsgType(msg.messageType)
const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000)
const isMine = msg.mesDes !== 1
const localId = parseInt(msg.mesLocalID) || 0
let content = msg.msgContent
let img = ''
let name = ''
if (isMine) {
if (myAvatar) img = myAvatar
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
} else {
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
}
if (content && typeof content === 'string') {
const colonIndex = content.indexOf(':')
if (colonIndex > 0) {
const potentialWxid = content.substring(0, colonIndex)
if (potentialWxid.startsWith('wxid_')) {
const member = dbRef!.getGroupMember(potentialWxid)
if (member) img = member.m_nsHeadImgUrl
if (groupMembers[potentialWxid]) {
name = groupMembers[potentialWxid]
content = content.substring(colonIndex + 1)
}
}
}
}
let contentData: ReturnType<typeof parseMessageContent> | undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const inferredMsgType =
typeof content === 'string' &&
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const parsed =
inferredMsgType === 47
? parseStickerMessageFromRow(msg, content)
: parseMessageContent(content, inferredMsgType)
if (parsed.type === 'system') {
content = parsed.content
contentData = parsed
} else if (parsed.type !== 'unknown') {
content = ''
}
if (parsed.type === 'image') {
const imageDatName = parseImageDatNameFromRow(msg)
contentData = { ...parsed, datName: parsed.datName || imageDatName }
} else if (parsed.type !== 'system') {
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
contentData = parsed
}
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
}
} catch {
// ignore parse errors
}
}
if (
!contentData &&
typeof content === 'string' &&
/^[0-9a-fA-F]{64,}$/.test(content.trim())
) {
const parsed = parseStickerMessageFromRow(msg, content)
if (parsed.type === 'sticker') {
if (!parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
content = ''
contentData = parsed
displayType = '表情包'
}
}
if (msgType === 34) content = '[语音消息]'
return {
id: msg.mesLocalID || Math.random().toString(),
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
isSender: isMine,
type: displayType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
content,
img,
name,
sessionId: username,
localId,
createTime,
contentData
}
})
}
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
if (!dbRef) return null
const wcdb4Client = dbRef.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = wcdb4Client
.getGroupMembers(roomId)
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
avatar: member.m_nsHeadImgUrl || ''
}))
return { roomId, memberCount: members.length, members }
}
export function searchMessages(keyword: string): string | null {
if (!dbRef) return null
return dbRef.searchAllMessages(keyword)
}
export function listRecentChat(limit = 50): FormattedContact[] {
const contacts = listContacts()
return contacts.slice(0, limit)
}
export function resolveMd5(query: string): FormattedContact | null {
const trimmed = query.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
const contacts = listContacts()
const exact = contacts.find(
(c) =>
c.md5 === trimmed ||
c.m_nsUsrName.toLowerCase() === lower ||
c.m_nsNickName.toLowerCase() === lower
)
if (exact) return exact
const partial = contacts.find(
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) ||
c.m_nsUsrName.toLowerCase().includes(lower)
)
return partial || null
}
export interface SelfAccountInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
export function getSelfAccountInfo(): SelfAccountInfo | null {
if (!dbRef) return null
const wcdb = dbRef.getWcdb4Client()
const accountRoot = wcdb.getAccountRoot()
const usernameCandidates = wcdb.getMyUsernameCandidates()
const primaryUsername = usernameCandidates[0] ?? ''
const wxid =
primaryUsername && primaryUsername.toLowerCase().startsWith('wxid_')
? primaryUsername
: wcdb.getUsernameByMd5(wcdb.md5(accountRoot.split('/').pop() || '')) || primaryUsername
let nickname = ''
let avatar: string | undefined
try {
avatar = wcdb.getMyAvatarUrl()
} catch {
avatar = undefined
}
if (usernameCandidates.length) {
const contacts = listContacts()
const self = contacts.find(
(c) =>
usernameCandidates.includes(c.m_nsUsrName) ||
(c.type === 'user' && usernameCandidates.some((u) => c.m_nsUsrName.includes(u)))
)
if (self) {
nickname = self.m_nsNickName
avatar = avatar || self.avatar
}
}
return {
wxid: wxid || primaryUsername || '',
nickname: nickname || wxid || '我',
avatar,
accountRoot
}
}
export function testConnection(
key: string,
accountRoot?: string
): { success: boolean; error?: string; accountRoot?: string; wxid?: string } {
try {
const probeKey = key.replace(/^0x/i, '').trim()
if (!probeKey) {
return { success: false, error: '密钥不能为空' }
}
const probe = accountRoot ? new WechatDb(probeKey, accountRoot) : new WechatDb(probeKey)
try {
probe.close()
} catch {
// best effort
}
return {
success: true,
accountRoot: probe.getWcdb4Client().getAccountRoot(),
wxid: (probe.getWcdb4Client().getMyUsernameCandidates?.() ?? [])[0] || ''
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
export function reopenWithRoot(accountRoot: string): boolean {
if (!dbRef) return false
const key = getCurrentKey()
if (!key) return false
try {
const next = new WechatDb(key, accountRoot)
setChatDb(next)
return true
} catch (error) {
console.error('[ChatService] reopen with root failed:', error)
return false
}
}
+76
View File
@@ -0,0 +1,76 @@
import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
export interface AppSettings {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
}
const DEFAULT_SETTINGS: AppSettings = {
dbRoot: path.join(
os.homedir(),
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
),
apiEnabled: true,
apiHost: '127.0.0.1',
apiPort: 6131
}
const SETTINGS_FILE = path.join(
process.env['WE_SETTINGS_DIR'] || app.getPath('userData'),
'settings.json'
)
let cache: AppSettings | null = null
function ensureDir(): void {
fs.ensureDirSync(path.dirname(SETTINGS_FILE))
}
export function loadSettings(): AppSettings {
if (cache) return cache
try {
if (fs.existsSync(SETTINGS_FILE)) {
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
cache = { ...DEFAULT_SETTINGS, ...raw }
return cache
}
} catch (error) {
console.warn('[Settings] failed to load, fallback to defaults:', error)
}
cache = { ...DEFAULT_SETTINGS }
return cache
}
export function saveSettings(next: AppSettings): AppSettings {
cache = { ...next }
try {
ensureDir()
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
} catch (error) {
console.error('[Settings] failed to save:', error)
}
return cache
}
export function updateSettings(patch: Partial<AppSettings>): AppSettings {
return saveSettings({ ...loadSettings(), ...patch })
}
export function resetSettings(): AppSettings {
cache = null
try {
if (fs.existsSync(SETTINGS_FILE)) fs.unlinkSync(SETTINGS_FILE)
} catch {
// best effort
}
return loadSettings()
}
export function getSettingsPath(): string {
return SETTINGS_FILE
}
+39 -2
View File
@@ -145,7 +145,9 @@ export class Wcdb4Client {
constructor(key: string, accountRoot?: string) { constructor(key: string, accountRoot?: string) {
this.key = key.replace(/^0x/i, '').trim() this.key = key.replace(/^0x/i, '').trim()
this.accountRoot = accountRoot || Wcdb4Client.findLatestAccountRoot() this.accountRoot = accountRoot
? Wcdb4Client.resolveAccountRoot(accountRoot)
: Wcdb4Client.findLatestAccountRoot()
this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot)) this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot))
this.dbStoragePath = path.join(this.accountRoot, 'db_storage') this.dbStoragePath = path.join(this.accountRoot, 'db_storage')
this.sessionDbPath = this.findSessionDb() this.sessionDbPath = this.findSessionDb()
@@ -155,6 +157,37 @@ export class Wcdb4Client {
} }
} }
static resolveAccountRoot(accountRoot: string): string {
const target = (accountRoot || '').trim().replace(/\/+$/, '')
if (!target) {
throw new Error('微信 4.0 账号目录不能为空')
}
if (fs.existsSync(path.join(target, 'db_storage'))) {
return target
}
if (!fs.existsSync(target)) {
throw new Error(`未找到微信 4.0 数据目录: ${target}`)
}
const candidates = fs
.readdirSync(target)
.map((name) => path.join(target, name))
.filter((candidate) => {
try {
return (
fs.statSync(candidate).isDirectory() &&
fs.existsSync(path.join(candidate, 'db_storage'))
)
} catch {
return false
}
})
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
if (!candidates[0]) {
throw new Error(`未找到包含 db_storage 的微信 4.0 账号目录: ${target}`)
}
return candidates[0]
}
static findLatestAccountRoot(): string { static findLatestAccountRoot(): string {
const root = Wcdb4Client.defaultRoot const root = Wcdb4Client.defaultRoot
if (!fs.existsSync(root)) { if (!fs.existsSync(root)) {
@@ -714,6 +747,10 @@ export class Wcdb4Client {
return this.accountRoot return this.accountRoot
} }
getKey(): string {
return this.key
}
resolveImageHardlink(md5: string): Wcdb4ImageHardlink | null { resolveImageHardlink(md5: string): Wcdb4ImageHardlink | null {
if (!this.wcdbResolveImageHardlink) return null if (!this.wcdbResolveImageHardlink) return null
const normalizedMd5 = String(md5 || '') const normalizedMd5 = String(md5 || '')
@@ -1390,7 +1427,7 @@ export class Wcdb4Client {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
} }
private getMyUsernameCandidates(): string[] { getMyUsernameCandidates(): string[] {
const rawAccountName = path.basename(this.accountRoot) const rawAccountName = path.basename(this.accountRoot)
return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)]) return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)])
} }
+2 -2
View File
@@ -34,9 +34,9 @@ export class WechatDb {
private wcdb4Client: Wcdb4Client private wcdb4Client: Wcdb4Client
private chatMd5ToUsername = new Map<string, string>() private chatMd5ToUsername = new Map<string, string>()
constructor(rawKey: string) { constructor(rawKey: string, accountRoot?: string) {
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`) console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
const client = new Wcdb4Client(rawKey) const client = new Wcdb4Client(rawKey, accountRoot)
client.open() client.open()
this.wcdb4Client = client this.wcdb4Client = client
for (const table of client.getChatTables()) { for (const table of client.getChatTables()) {
+62
View File
@@ -78,9 +78,71 @@ declare global {
warning?: string warning?: string
}> }>
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
saveDbKey: (key: string) => Promise<{ success: boolean; key?: string; error?: string }>
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
getSettings: () => Promise<{
settings: {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
}
settingsPath: string
}>
setSettings: (patch: Partial<{
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
}>) => Promise<{
settings: {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
}
settingsPath: string
}>
getSelf: () => Promise<
| {
ready: true
info: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
}
| { ready: false }
>
testConnection: (
key: string,
accountRoot?: string
) => Promise<{
success: boolean
error?: string
accountRoot?: string
wxid?: string
}>
reopenWithRoot: (accountRoot: string) => Promise<{
success: boolean
error?: string
info?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
}>
apiStatus: () => Promise<{
running: boolean
host: string
port: number
error?: string
}>
apiStart: (
host?: string,
port?: number
) => Promise<{ running: boolean; host: string; port: number; error?: string }>
apiStop: () => Promise<{ running: boolean; host: string; port: number; error?: string }>
apiToggle: (enabled: boolean) => Promise<{
running: boolean
host: string
port: number
error?: string
}>
} }
} }
} }
+12 -1
View File
@@ -28,6 +28,7 @@ const api = {
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'), getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'), pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'), clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => { onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
const listener = ( const listener = (
@@ -42,7 +43,17 @@ const api = {
callback(payload) callback(payload)
ipcRenderer.on('key:dbKeyStatus', listener) ipcRenderer.on('key:dbKeyStatus', listener)
return () => ipcRenderer.removeListener('key:dbKeyStatus', listener) return () => ipcRenderer.removeListener('key:dbKeyStatus', listener)
} },
getSettings: () => ipcRenderer.invoke('settings:get'),
setSettings: (patch) => ipcRenderer.invoke('settings:set', patch),
getSelf: () => ipcRenderer.invoke('settings:getSelf'),
testConnection: (key: string, accountRoot?: string) =>
ipcRenderer.invoke('db:testConnection', key, accountRoot),
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
apiStop: () => ipcRenderer.invoke('api:stop'),
apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled)
} }
if (process.contextIsolated) { if (process.contextIsolated) {
+118 -13
View File
@@ -1,8 +1,16 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Sidebar } from './components/Sidebar' import { Sidebar } from './components/Sidebar'
import ChatWindow from './components/ChatWindow' import ChatWindow from './components/ChatWindow'
import { SettingsPanel } from './components/SettingsPanel'
import { Contact, Message } from '../../shared/types' import { Contact, Message } from '../../shared/types'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const MESSAGE_MONITOR_DEBOUNCE_MS = 250 const MESSAGE_MONITOR_DEBOUNCE_MS = 250
@@ -96,20 +104,87 @@ function App(): React.ReactElement {
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal') const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
const [showDbKey, setShowDbKey] = useState(false) const [showDbKey, setShowDbKey] = useState(false)
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false) const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false) const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null) const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({}) const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
const refreshSelfInfo = async (): Promise<void> => {
try {
const result = await window.api.getSelf()
if (result.ready) {
setSelfInfo(result.info)
} else {
setSelfInfo(null)
}
} catch (error) {
console.warn('[SelfInfo] 加载失败:', error)
setSelfInfo(null)
}
}
const loadContacts = async (): Promise<void> => {
const list = await window.api.getContacts()
setContacts(list)
setFilteredContacts(list)
}
React.useEffect(() => { React.useEffect(() => {
let active = true let active = true
void window.api.getSavedDbKey().then((result) => { const attemptAutoConnect = async (): Promise<void> => {
if (!active) return // 优先级 1:构建期环境变量 VITE_DB_KEY(本地开发/打包时硬编码的密钥)
if (result.success && result.key) { const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
setDbKey(result.key) // 优先级 2:上一次保存到 safeStorage 的密钥
setDbKeyStatus('已加载安全保存的密钥') let savedKey = ''
setDbKeyStatusKind('success') if (!envKey) {
const result = await window.api.getSavedDbKey()
if (result.success && result.key) savedKey = result.key
} }
}) const key = envKey || savedKey
if (!key) {
if (active) setBootState('login')
return
}
if (active) {
setBootState('connecting')
setDbKey(key)
setAutoConnectSource(envKey ? 'env' : 'saved')
setDbKeyStatus(
envKey ? '检测到环境变量中的密钥,正在自动连接...' : '已加载安全保存的密钥,正在自动连接...'
)
setDbKeyStatusKind('normal')
}
try {
const result = await window.api.initDb(key)
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsAuthenticated(true)
setDbKeyStatus('已自动连接')
setDbKeyStatusKind('success')
await loadContacts()
void refreshSelfInfo()
} else {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(
`自动连接失败,请重新输入${error ? `: ${error}` : ''}`
)
setDbKeyStatusKind('error')
setBootState('login')
}
} catch (error) {
if (!active) return
const message = error instanceof Error ? error.message : String(error)
setDbKeyStatus(`自动连接失败: ${message}`)
setDbKeyStatusKind('error')
setBootState('login')
}
}
void attemptAutoConnect()
const unsubscribe = window.api.onDbKeyStatus(({ message }) => { const unsubscribe = window.api.onDbKeyStatus(({ message }) => {
if (!active) return if (!active) return
setDbKeyStatus(message) setDbKeyStatus(message)
@@ -136,7 +211,10 @@ function App(): React.ReactElement {
if (success) { if (success) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsAuthenticated(true) setIsAuthenticated(true)
// 手动输入也持久化,下次启动可自动连接(参考 WeFlow)
void window.api.saveDbKey(keyToUse).catch(() => undefined)
loadContacts() loadContacts()
void refreshSelfInfo()
} else { } else {
const error = typeof result === 'boolean' ? '' : result.error const error = typeof result === 'boolean' ? '' : result.error
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`) alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
@@ -226,12 +304,6 @@ function App(): React.ReactElement {
setDbKeyStatusKind('normal') setDbKeyStatusKind('normal')
} }
const loadContacts = async (): Promise<void> => {
const list = await window.api.getContacts()
setContacts(list)
setFilteredContacts(list)
}
const getDateRangeParams = ( const getDateRangeParams = (
range: string range: string
): { startTime: number | undefined; endTime: number | undefined } => { ): { startTime: number | undefined; endTime: number | undefined } => {
@@ -409,6 +481,24 @@ function App(): React.ReactElement {
} }
}, [resize, stopResizing]) }, [resize, stopResizing])
if (!isAuthenticated && bootState !== 'login') {
return (
<div className="boot-splash">
<div className="boot-splash-spinner" aria-hidden />
<div className="boot-splash-title">
{bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...'}
</div>
<div className="boot-splash-subtitle">
{bootState === 'connecting'
? autoConnectSource === 'env'
? '检测到环境变量中的密钥'
: '使用上次安全保存的密钥'
: 'WechatExplorer'}
</div>
</div>
)
}
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="login-modal"> <div className="login-modal">
@@ -476,6 +566,9 @@ function App(): React.ReactElement {
width={sidebarWidth} width={sidebarWidth}
dateRange={dateRange} dateRange={dateRange}
onDateRangeChange={handleDateRangeChange} onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onOpenSettings={() => setShowSettings(true)}
/> />
<div className="resizer" onMouseDown={startResizing} /> <div className="resizer" onMouseDown={startResizing} />
<ChatWindow <ChatWindow
@@ -486,6 +579,18 @@ function App(): React.ReactElement {
onRefresh={() => selectedContact && handleSelectContact(selectedContact)} onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefreshData={loadContacts} onRefreshData={loadContacts}
/> />
<SettingsPanel
open={showSettings}
selfInfo={selfInfo}
dbReady={isAuthenticated}
dbKey={dbKey}
onClose={() => setShowSettings(false)}
onDbKeyChange={setDbKey}
onDbRootChanged={() => {
void refreshSelfInfo()
void loadContacts()
}}
/>
</div> </div>
) )
} }
+405
View File
@@ -1204,3 +1204,408 @@ body {
.voip-status { .voip-status {
font-size: 13px; font-size: 13px;
} }
/* Sidebar 自助卡片 + 入口 */
.sidebar-footer {
padding: 10px 12px;
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
background-color: #f3f4f5;
transition: background-color 0.15s ease;
user-select: none;
}
.sidebar-footer:hover {
background-color: #e6e9eb;
}
.sidebar-self-avatar {
width: 36px;
height: 36px;
border-radius: 6px;
background-color: #07c160;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
overflow: hidden;
flex-shrink: 0;
}
.sidebar-self-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.sidebar-self-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.sidebar-self-nickname {
font-size: 13px;
font-weight: 500;
color: #1f2429;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-self-wxid {
font-size: 11px;
color: #8a9298;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-self-arrow {
color: #8a9298;
font-size: 18px;
flex-shrink: 0;
}
/* 启动自动连接 Splash */
.boot-splash {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: linear-gradient(180deg, #f5f7f8 0%, #eceff1 100%);
z-index: 2000;
animation: boot-splash-fade-in 0.2s ease-out;
}
@keyframes boot-splash-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.boot-splash-spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid rgba(7, 193, 96, 0.18);
border-top-color: #07c160;
animation: boot-splash-spin 0.9s linear infinite;
}
@keyframes boot-splash-spin {
to {
transform: rotate(360deg);
}
}
.boot-splash-title {
font-size: 15px;
font-weight: 500;
color: #1f2429;
}
.boot-splash-subtitle {
font-size: 12px;
color: #6f767c;
}
/* 设置面板 */
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(15, 21, 26, 0.45);
backdrop-filter: blur(2px);
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
animation: settings-fade-in 0.16s ease-out;
}
@keyframes settings-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.settings-modal {
width: min(560px, 92vw);
max-height: 84vh;
background: #fff;
border-radius: 12px;
box-shadow: 0 24px 60px rgba(15, 21, 26, 0.28);
display: flex;
flex-direction: column;
overflow: hidden;
animation: settings-pop-in 0.18s ease-out;
}
@keyframes settings-pop-in {
from {
transform: translateY(8px) scale(0.98);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 22px;
border-bottom: 1px solid #ececec;
}
.settings-header h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
color: #1f2429;
}
.settings-close {
background: transparent;
border: 0;
color: #7d858a;
font-size: 26px;
line-height: 1;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.15s ease;
}
.settings-close:hover {
background: #f0f2f4;
color: #1f2429;
}
.settings-body {
padding: 12px 22px 22px;
overflow-y: auto;
}
.settings-section {
margin-top: 14px;
padding: 14px 16px;
border-radius: 10px;
background: #fafbfc;
border: 1px solid #ececec;
}
.settings-section:first-child {
margin-top: 6px;
}
.settings-section-title {
font-size: 12px;
font-weight: 600;
color: #6f767c;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 10px;
}
.settings-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
.settings-row:first-child {
margin-top: 0;
}
.settings-input {
flex: 1;
min-width: 0;
padding: 8px 11px;
border: 1px solid #d4d9dc;
border-radius: 6px;
background: #fff;
font-size: 13px;
color: #1f2429;
font-family: 'SF Mono', Menlo, Consolas, monospace;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.settings-input:focus {
border-color: #07c160;
box-shadow: 0 0 0 3px rgba(7, 193, 96, 0.12);
}
.settings-input-half {
flex: 0 1 140px;
}
.settings-input-quarter {
flex: 0 1 90px;
}
.settings-btn {
padding: 7px 14px;
border: 1px solid #d4d9dc;
border-radius: 6px;
background: #fff;
color: #30383d;
cursor: pointer;
font-size: 13px;
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
}
.settings-btn:hover:not(:disabled) {
border-color: #07c160;
color: #078f49;
}
.settings-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.settings-btn-primary {
background: #07c160;
color: #fff;
border-color: #07c160;
}
.settings-btn-primary:hover:not(:disabled) {
background: #06ad56;
color: #fff;
border-color: #06ad56;
}
.settings-hint {
margin-top: 10px;
font-size: 11px;
color: #8a9298;
line-height: 1.6;
}
.settings-hint code {
background: #eef0f2;
padding: 1px 5px;
border-radius: 3px;
font-size: 10.5px;
}
.settings-status {
font-size: 12px;
color: #59636a;
}
.settings-status.ok {
color: #078f49;
}
.settings-status.fail {
color: #c73737;
}
.settings-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: #30383d;
}
.settings-toggle input {
width: 16px;
height: 16px;
accent-color: #07c160;
cursor: pointer;
}
.settings-self {
display: flex;
align-items: center;
gap: 12px;
}
.settings-self-avatar {
width: 56px;
height: 56px;
border-radius: 10px;
background: #07c160;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 500;
overflow: hidden;
flex-shrink: 0;
}
.settings-self-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.settings-self-info {
flex: 1;
min-width: 0;
}
.settings-self-nickname {
font-size: 15px;
font-weight: 600;
color: #1f2429;
}
.settings-self-wxid {
font-size: 12px;
color: #6f767c;
margin-top: 2px;
font-family: 'SF Mono', Menlo, Consolas, monospace;
}
.settings-self-account {
font-size: 11px;
color: #8a9298;
margin-top: 2px;
word-break: break-all;
}
.settings-self-empty {
font-size: 13px;
color: #8a9298;
}
.settings-path {
display: inline-block;
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 11px;
background: #eef0f2;
padding: 3px 7px;
border-radius: 4px;
color: #30383d;
word-break: break-all;
max-width: 100%;
}
@@ -0,0 +1,306 @@
import React, { useEffect, useState } from 'react'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface AppSettings {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
}
interface ApiState {
running: boolean
host: string
port: number
error?: string
}
interface SettingsPanelProps {
open: boolean
selfInfo: SelfInfo | null
dbReady: boolean
dbKey: string
onClose: () => void
onDbKeyChange: (key: string) => void
onDbRootChanged: () => void
}
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
open,
selfInfo,
dbReady,
dbKey,
onClose,
onDbKeyChange,
onDbRootChanged
}) => {
const [settings, setSettings] = useState<AppSettings | null>(null)
const [settingsPath, setSettingsPath] = useState('')
const [apiState, setApiState] = useState<ApiState | null>(null)
const [testStatus, setTestStatus] = useState<
{ kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string }
>({ kind: 'idle', message: '' })
const [reopenStatus, setReopenStatus] = useState<string>('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!open) return
void refresh()
}, [open])
async function refresh(): Promise<void> {
const [{ settings, settingsPath }, api] = await Promise.all([
window.api.getSettings(),
window.api.apiStatus()
])
setSettings(settings)
setSettingsPath(settingsPath)
setApiState(api)
}
if (!open) return null
async function handleSave(patch: Partial<AppSettings>): Promise<void> {
if (!settings) return
setBusy(true)
const next = await window.api.setSettings(patch)
setSettings(next.settings)
setBusy(false)
}
async function handleTest(): Promise<void> {
setTestStatus({ kind: 'idle', message: '测试中...' })
setBusy(true)
try {
const result = await window.api.testConnection(dbKey, settings?.dbRoot)
if (result.success) {
setTestStatus({
kind: 'ok',
message: '连接成功',
wxid: result.wxid,
accountRoot: result.accountRoot
})
if (result.accountRoot && settings && result.accountRoot !== settings.dbRoot) {
const next = await window.api.setSettings({ dbRoot: result.accountRoot })
setSettings(next.settings)
}
} else {
setTestStatus({ kind: 'fail', message: result.error || '连接失败' })
}
} catch (error) {
setTestStatus({ kind: 'fail', message: error instanceof Error ? error.message : String(error) })
} finally {
setBusy(false)
}
}
async function handleReopen(): Promise<void> {
if (!settings) return
setBusy(true)
setReopenStatus('重新初始化中...')
try {
const result = await window.api.reopenWithRoot(settings.dbRoot)
if (result.success) {
setReopenStatus(`已重新打开:${result.info?.wxid || '未知'}`)
onDbRootChanged()
} else {
setReopenStatus(result.error || '重新打开失败')
}
} catch (error) {
setReopenStatus(error instanceof Error ? error.message : String(error))
} finally {
setBusy(false)
}
}
async function handleApiToggle(enabled: boolean): Promise<void> {
setBusy(true)
await handleSave({ apiEnabled: enabled })
const state = await window.api.apiToggle(enabled)
setApiState(state)
setBusy(false)
}
async function handleApiRestart(): Promise<void> {
if (!settings) return
setBusy(true)
await window.api.apiStop()
const state = await window.api.apiStart(settings.apiHost, settings.apiPort)
setApiState(state)
setBusy(false)
}
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-header">
<h2></h2>
<button className="settings-close" onClick={onClose} title="关闭">
×
</button>
</div>
<div className="settings-body">
{/* 自我信息卡片 */}
<section className="settings-section">
<div className="settings-section-title"></div>
{dbReady && selfInfo ? (
<div className="settings-self">
<div className="settings-self-avatar">
{selfInfo.avatar ? (
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
) : (
(selfInfo.nickname || selfInfo.wxid || '?').charAt(0)
)}
</div>
<div className="settings-self-info">
<div className="settings-self-nickname">{selfInfo.nickname}</div>
<div className="settings-self-wxid">{selfInfo.wxid}</div>
<div className="settings-self-account">{selfInfo.accountRoot}</div>
</div>
</div>
) : (
<div className="settings-self-empty"></div>
)}
</section>
{/* 测试连接 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<button
className="settings-btn settings-btn-primary"
onClick={handleTest}
disabled={busy || !dbKey}
>
</button>
{testStatus.kind !== 'idle' && (
<span className={`settings-status ${testStatus.kind}`}>
{testStatus.kind === 'ok' ? '✓' : '✗'} {testStatus.message}
{testStatus.wxid ? ` · ${testStatus.wxid}` : ''}
{testStatus.accountRoot ? ` · ${testStatus.accountRoot}` : ''}
</span>
)}
</div>
<div className="settings-hint">
使 + ,
</div>
</section>
{/* 解密密钥 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={dbKey}
onChange={(e) => onDbKeyChange(e.target.value)}
placeholder="64 位 hex 密钥,如 0x..."
spellCheck={false}
/>
</div>
<div className="settings-hint">
macOS Keychain(safeStorage ),
</div>
</section>
{/* 数据库根目录 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={settings?.dbRoot ?? ''}
onChange={(e) => setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)}
onBlur={(e) => handleSave({ dbRoot: e.target.value })}
placeholder="~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"
spellCheck={false}
/>
</div>
<div className="settings-row">
<button
className="settings-btn"
onClick={handleReopen}
disabled={busy || !dbReady}
>
</button>
{reopenStatus && <span className="settings-status">{reopenStatus}</span>}
</div>
<div className="settings-hint">
xwechat_files , db_storage/
</div>
</section>
{/* API 服务 */}
<section className="settings-section">
<div className="settings-section-title"> HTTP API</div>
<div className="settings-row">
<label className="settings-toggle">
<input
type="checkbox"
checked={settings?.apiEnabled ?? false}
onChange={(e) => handleApiToggle(e.target.checked)}
disabled={busy}
/>
<span> API (127.0.0.1:6131)</span>
</label>
{apiState && (
<span className={`settings-status ${apiState.running ? 'ok' : 'fail'}`}>
{apiState.running ? '运行中' : '已停止'}
{apiState.error ? ` · ${apiState.error}` : ''}
</span>
)}
</div>
<div className="settings-row">
<input
type="text"
className="settings-input settings-input-half"
value={settings?.apiHost ?? ''}
onChange={(e) => setSettings(settings ? { ...settings, apiHost: e.target.value } : null)}
onBlur={(e) => handleSave({ apiHost: e.target.value })}
placeholder="host"
spellCheck={false}
/>
<input
type="number"
className="settings-input settings-input-quarter"
value={settings?.apiPort ?? 6131}
onChange={(e) =>
setSettings(settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null)
}
onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })}
placeholder="port"
/>
<button className="settings-btn" onClick={handleApiRestart} disabled={busy}>
API
</button>
</div>
<div className="settings-hint">
API 访, Claude / Codex
<br />
:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
</div>
</section>
{/* 配置文件位置 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<code className="settings-path">{settingsPath}</code>
</div>
</section>
</div>
</div>
</div>
)
}
+32 -9
View File
@@ -1,6 +1,13 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Contact } from '../../../shared/types' import { Contact } from '../../../shared/types'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface SidebarProps { interface SidebarProps {
contacts: Contact[] contacts: Contact[]
selectedContact: Contact | null selectedContact: Contact | null
@@ -10,6 +17,9 @@ interface SidebarProps {
width: number width: number
dateRange: string dateRange: string
onDateRangeChange: (range: string) => void onDateRangeChange: (range: string) => void
selfInfo: SelfInfo | null
dbReady: boolean
onOpenSettings: () => void
} }
export const Sidebar: React.FC<SidebarProps> = ({ export const Sidebar: React.FC<SidebarProps> = ({
@@ -20,7 +30,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
onContentFilter, onContentFilter,
width, width,
dateRange, dateRange,
onDateRangeChange onDateRangeChange,
selfInfo,
dbReady,
onOpenSettings
}) => { }) => {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [contentFilter, setContentFilter] = useState('') const [contentFilter, setContentFilter] = useState('')
@@ -115,14 +128,24 @@ export const Sidebar: React.FC<SidebarProps> = ({
<div className="section-empty"></div> <div className="section-empty"></div>
)} )}
</div> </div>
{/* <div className="sidebar-footer"> <div className="sidebar-footer" onClick={onOpenSettings} title="设置">
<div className="sidebar-btn" onClick={() => window.location.reload()}> <div className="sidebar-self-avatar">
<span className="icon">↪️</span> 退出 {selfInfo?.avatar ? (
</div> <img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
<div className="sidebar-status"> ) : (
✅ 已获得 ((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0))
</div> )}
</div> */} </div>
<div className="sidebar-self-info">
<div className="sidebar-self-nickname">
{dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '我' : '未连接'}
</div>
<div className="sidebar-self-wxid">
{dbReady && selfInfo ? selfInfo.wxid : '点击设置 →'}
</div>
</div>
<div className="sidebar-self-arrow" aria-hidden></div>
</div>
</div> </div>
) )
} }
+15 -3
View File
@@ -169,9 +169,21 @@ export const buildGroupReportInput = (
const dateRange = sameDay const dateRange = sameDay
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}` ? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
: `${startDate} ${localTime(firstTimestamp)}${endDate} ${localTime(lastTimestamp)}` : `${startDate} ${localTime(firstTimestamp)}${endDate} ${localTime(lastTimestamp)}`
const timeSpan = sameDay // 模板"持续时长"格子:首条到末条消息的时长,紧凑半角格式
? `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 3600000))}小时` const durationMs = Math.max(0, lastTimestamp - firstTimestamp)
: `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 86400000))}` const durationHours = durationMs / 3600000
const timeSpan = (() => {
if (sameDay) {
if (durationHours < 1) {
const minutes = Math.max(1, Math.round(durationMs / 60000))
return `${minutes} min`
}
const hours = Math.max(1, Math.ceil(durationHours))
return `${hours} h`
}
const days = Math.max(1, Math.ceil(durationMs / 86400000))
return `${days} d`
})()
const contactName = contact?.m_nsNickName || '' const contactName = contact?.m_nsNickName || ''
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话' const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
const metadata: GroupReportMetadata = { const metadata: GroupReportMetadata = {
+8
View File
@@ -76,6 +76,13 @@ export interface GroupReportMetadata {
footerNote: string footerNote: string
heroParticipants: string[] heroParticipants: string[]
avatars: Record<string, string | undefined> avatars: Record<string, string | undefined>
// === 新增(可选,向后兼容) ===
/** 群昵称 / wxid / md5,服务端用来反推真头像(从 getGroupSnapshot) */
talker?: string
/** 预留,与 /api/v1/chatlog 的 time 参数同格式 */
timeRange?: string
/** 服务端写回,告知 client enrich 失败/部分缺失 */
warnings?: string[]
} }
export interface GroupReportExportRequest { export interface GroupReportExportRequest {
@@ -88,5 +95,6 @@ export interface GroupReportExportResult {
htmlPath?: string htmlPath?: string
pngPath?: string pngPath?: string
imageDataUrl?: string imageDataUrl?: string
warnings?: string[]
error?: string error?: string
} }