mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 20:19:09 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d079d45279 | ||
|
|
b39cb362f7 | ||
|
|
f9e629948d | ||
|
|
a0b163823d | ||
|
|
3eb4438f4c | ||
|
|
7932ea2f54 | ||
|
|
4d14ce867c |
+1
-1
@@ -4,7 +4,7 @@ VITE_DB_KEY=
|
|||||||
# AI API Configuration (Optional, can be entered in UI)
|
# AI API Configuration (Optional, can be entered in UI)
|
||||||
VITE_DEEPSEEK_API_KEY=
|
VITE_DEEPSEEK_API_KEY=
|
||||||
VITE_AI_BASE_URL=https://api.deepseek.com
|
VITE_AI_BASE_URL=https://api.deepseek.com
|
||||||
VITE_AI_MODEL=deepseek-chat
|
VITE_AI_MODEL=deepseek-v4-flash
|
||||||
|
|
||||||
# Message types to filter out (comma separated). Empty means show all message types.
|
# Message types to filter out (comma separated). Empty means show all message types.
|
||||||
VITE_FILTER_MSG_TYPES=
|
VITE_FILTER_MSG_TYPES=
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
docs/
|
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
|||||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||||
|
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
|
||||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||||
- **问题与解答**: 整理群内的问答内容。
|
- **问题与解答**: 整理群内的问答内容。
|
||||||
|
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
|
||||||
|
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
|
||||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||||
- **词云/关键词**: 可视化展示群聊关键词。
|
- **词云/关键词**: 可视化展示群聊关键词。
|
||||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||||
@@ -83,6 +86,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=摸鱼交流群"
|
||||||
|
```
|
||||||
|
|
||||||
## ⚠️ 免责声明
|
## ⚠️ 免责声明
|
||||||
|
|
||||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||||
@@ -90,5 +161,5 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
|||||||
## 🔗 参考
|
## 🔗 参考
|
||||||
|
|
||||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||||
- [WechatExplorer](https://github.com/hicccc77/WechatExplorer)
|
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||||
- [chatlog](https://github.com/sjzar/chatlog)
|
- [chatlog](https://github.com/sjzar/chatlog)
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
---
|
||||||
|
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": "解答人(可选)" }
|
||||||
|
],
|
||||||
|
"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 }],
|
||||||
|
"voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
|
||||||
|
},
|
||||||
|
"keywords": ["高频词1", "高频词2"],
|
||||||
|
"hero": {
|
||||||
|
"headline": "一句抓重点的日报标题",
|
||||||
|
"summary": "一句概览",
|
||||||
|
"keyTakeaway": "最重要结论",
|
||||||
|
"pendingNote": "待跟进事项"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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 支持情况调整)
|
||||||
@@ -27,13 +27,13 @@
|
|||||||
.report {
|
.report {
|
||||||
width: 430px;
|
width: 430px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 22px 14px 34px;
|
padding: 20px 14px 34px;
|
||||||
}
|
}
|
||||||
.hero,
|
.hero,
|
||||||
.card,
|
.section,
|
||||||
.section {
|
.card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
@@ -41,20 +41,35 @@
|
|||||||
}
|
}
|
||||||
.hero-top {
|
.hero-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
.hero-top > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
font-size: 23px;
|
font-size: 23px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin: 0 0 8px;
|
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.sub {
|
.sub {
|
||||||
|
margin-top: 8px;
|
||||||
color: #667085;
|
color: #667085;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.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 {
|
.avatar-grid {
|
||||||
width: 58px;
|
width: 58px;
|
||||||
@@ -71,6 +86,49 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
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 {
|
.stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
@@ -87,22 +145,23 @@
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #07a352;
|
color: #07a352;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
.stat span {
|
.stat span {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #667085;
|
color: #667085;
|
||||||
}
|
}
|
||||||
.section {
|
.section {
|
||||||
margin-top: 14px;
|
margin-top: 18px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
.section-title {
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 18px;
|
margin-bottom: 14px;
|
||||||
|
font-size: 19px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
.section-title::before {
|
.section-title::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -111,11 +170,23 @@
|
|||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
background: #07c160;
|
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 {
|
.card {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
box-shadow: none;
|
|
||||||
border: 1px solid #edf0f2;
|
border: 1px solid #edf0f2;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.topic-title-row {
|
.topic-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -124,17 +195,17 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.topic-title-row h3 {
|
.topic-title-row h3 {
|
||||||
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
margin: 0;
|
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
.heat,
|
.heat,
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 999px;
|
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
background: #eef8f2;
|
background: #eef8f2;
|
||||||
color: #07a352;
|
color: #07a352;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -149,19 +220,44 @@
|
|||||||
background: #eef5ff;
|
background: #eef5ff;
|
||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
.red {
|
|
||||||
background: #fff1f0;
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
.topic-meta {
|
.topic-meta {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
color: #8a94a6;
|
color: #8a94a6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.card p {
|
.card p {
|
||||||
|
margin: 10px 0 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
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 {
|
.participants {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -173,7 +269,6 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
min-width: 0;
|
|
||||||
background: #f6f8fa;
|
background: #f6f8fa;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 3px 8px 3px 3px;
|
padding: 3px 8px 3px 3px;
|
||||||
@@ -182,7 +277,6 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
.person-chip b {
|
.person-chip b {
|
||||||
max-width: 58px;
|
max-width: 58px;
|
||||||
@@ -204,17 +298,6 @@
|
|||||||
background: #f2f4f7;
|
background: #f2f4f7;
|
||||||
color: #667085;
|
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 {
|
.important-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -239,9 +322,9 @@
|
|||||||
color: #1f2933;
|
color: #1f2933;
|
||||||
}
|
}
|
||||||
.important-text {
|
.important-text {
|
||||||
|
margin-top: 5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
margin-top: 5px;
|
|
||||||
}
|
}
|
||||||
.important-note {
|
.important-note {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -253,6 +336,34 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
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 {
|
.chat-block {
|
||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -283,49 +394,126 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.quote-note {
|
.quote-note {
|
||||||
background: #fff8e1;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff8e1;
|
||||||
color: #8a5a00;
|
color: #8a5a00;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.qa-card {
|
.qa-card,
|
||||||
background: #f8fafc;
|
.resource {
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
.qa-card b {
|
.qa-card b,
|
||||||
|
.resource b {
|
||||||
display: block;
|
display: block;
|
||||||
color: #1f2933;
|
color: #1f2933;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
.qa-card div {
|
.qa-card div,
|
||||||
|
.resource {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: #485465;
|
color: #485465;
|
||||||
}
|
}
|
||||||
.bar-row {
|
.storyline-card,
|
||||||
|
.chain-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.storyline-steps {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 82px 1fr;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
margin-top: 10px;
|
||||||
margin-top: 9px;
|
}
|
||||||
|
.storyline-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.storyline-step span {
|
||||||
|
color: #8a94a6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.bar {
|
.storyline-step b {
|
||||||
height: 10px;
|
font-size: 13px;
|
||||||
background: #edf1f5;
|
line-height: 1.5;
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.bar i {
|
.chain-flow {
|
||||||
display: block;
|
display: flex;
|
||||||
height: 100%;
|
flex-wrap: wrap;
|
||||||
background: #07c160;
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.chain-flow span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 9px;
|
||||||
border-radius: 999px;
|
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;
|
||||||
|
}
|
||||||
|
.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 {
|
.rank {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -334,6 +522,9 @@
|
|||||||
padding: 9px 0;
|
padding: 9px 0;
|
||||||
border-bottom: 1px solid #eef0f2;
|
border-bottom: 1px solid #eef0f2;
|
||||||
}
|
}
|
||||||
|
.rank:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.rank img {
|
.rank img {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -386,7 +577,58 @@
|
|||||||
color: #8a94a6;
|
color: #8a94a6;
|
||||||
}
|
}
|
||||||
.empty-section {
|
.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) {
|
@media (max-width: 430px) {
|
||||||
html,
|
html,
|
||||||
@@ -398,75 +640,135 @@
|
|||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
.stats {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.stat b {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="{{REPORT_MODE_CLASS}}">
|
||||||
<main class="report">
|
<main class="report">
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="hero-top">
|
<div class="hero-top">
|
||||||
<div>
|
<div>
|
||||||
<h1>{{GROUP_NAME}}日报</h1>
|
<h1>{{GROUP_NAME}}日报</h1>
|
||||||
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||||
|
<div class="mode-tag">{{REPORT_MODE_LABEL}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||||
</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="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>{{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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="section topics">
|
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||||
<div class="section-title">今日讨论热点</div>
|
<div class="section-title">今日讨论热点</div>
|
||||||
{{TOPIC_CARDS}}
|
{{TOPIC_CARDS}}
|
||||||
|
{{TOPICS_MORE_NOTE}}
|
||||||
</section>
|
</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>
|
<div class="section-title">实用信息与资源</div>
|
||||||
{{RESOURCE_ITEMS}}
|
{{RESOURCE_ITEMS}}
|
||||||
|
{{RESOURCES_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
<section class="section {{QA_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>
|
<div class="section-title">问题与解答</div>
|
||||||
{{QA_CARDS}}
|
{{QA_CARDS}}
|
||||||
|
{{QA_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section analytics">
|
<section class="section {{STORYLINES_EMPTY_CLASS}}">
|
||||||
<div class="section-title">群内数据可视化</div>
|
<div class="section-title">今日剧情时间线</div>
|
||||||
{{HEAT_BARS}}
|
{{STORYLINE_CARDS}}
|
||||||
<div class="card">
|
{{STORYLINES_MORE_NOTE}}
|
||||||
<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>
|
||||||
|
|
||||||
<section class="section cloud">
|
<section class="section {{REVERSALS_EMPTY_CLASS}}">
|
||||||
<div class="section-title">词云/关键词</div>
|
<div class="section-title">群聊反转现场</div>
|
||||||
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
{{REVERSAL_CARDS}}
|
||||||
|
{{REVERSALS_MORE_NOTE}}
|
||||||
|
</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>
|
</section>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
@@ -93,6 +140,29 @@ const heatClass = (heat: ReportHeat): string => {
|
|||||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||||
html.replaceAll(`{{${key}}}`, value)
|
html.replaceAll(`{{${key}}}`, value)
|
||||||
|
|
||||||
|
const modeLabel = (mode: GroupReportMetadata['reportMode']): string =>
|
||||||
|
mode === 'full' ? '完整版' : '精简版'
|
||||||
|
|
||||||
|
const sectionMeta = (
|
||||||
|
request: GroupReportExportRequest,
|
||||||
|
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||||
|
) => 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 renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||||
const { report, metadata } = request
|
const { report, metadata } = request
|
||||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||||
@@ -102,6 +172,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||||
)
|
)
|
||||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
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>()
|
const avatars = new Map<string, string>()
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -123,7 +196,21 @@ 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-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>
|
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||||
<p>${escapeHtml(topic.summary)}</p>
|
<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
|
||||||
|
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
<div class="participants">${topic.participants
|
<div class="participants">${topic.participants
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(
|
.map(
|
||||||
@@ -168,18 +255,104 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
const qaCards = report.qa
|
const todoCards = (report.todos || [])
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(item) => `<div class="action-card todo-card">
|
||||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
<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('')
|
.join('')
|
||||||
|
|
||||||
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
|
const unresolvedCards = (report.unresolved || [])
|
||||||
const heatBars = report.analytics.topicHeat
|
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(item) => `<div class="action-card unresolved-card">
|
||||||
`<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>`
|
<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('')
|
||||||
|
|
||||||
|
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('')
|
.join('')
|
||||||
|
|
||||||
@@ -199,30 +372,98 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
)
|
)
|
||||||
.join('')
|
.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 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(), 'utf8')
|
let html = await fs.readFile(templatePath(), 'utf8')
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||||
|
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||||
RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()),
|
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||||
|
REPORT_MODE_LABEL: escapeHtml(modeLabel(metadata.reportMode)),
|
||||||
|
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,
|
HERO_AVATARS: heroAvatars,
|
||||||
MESSAGE_COUNT: String(metadata.messageCount),
|
MESSAGE_COUNT: String(summaryStats.messageCount),
|
||||||
ACTIVE_USERS: String(metadata.activeUsers),
|
ACTIVE_USERS: String(summaryStats.activeUsers),
|
||||||
TIME_SPAN: escapeHtml(metadata.timeSpan),
|
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
||||||
TOPIC_COUNT: String(report.topics.length),
|
TOPIC_COUNT: String(summaryStats.topicCount),
|
||||||
|
MEDIA_COUNT: String(summaryStats.mediaCount),
|
||||||
TOPIC_CARDS: topicCards,
|
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,
|
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,
|
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,
|
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,
|
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'),
|
||||||
|
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,
|
RANK_ITEMS: rankItems,
|
||||||
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
||||||
|
CONCLUSION_COUNT: String(summaryStats.conclusionCount),
|
||||||
|
TODO_COUNT: String(summaryStats.todoCount),
|
||||||
|
UNRESOLVED_COUNT: String(summaryStats.unresolvedCount),
|
||||||
CLOUD_TAGS: cloudTags,
|
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),
|
GENERATED_AT: escapeHtml(metadata.generatedAt),
|
||||||
FOOTER_NOTE: escapeHtml(metadata.footerNote)
|
FOOTER_NOTE: escapeHtml(metadata.footerNote)
|
||||||
}
|
}
|
||||||
@@ -280,15 +521,24 @@ 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}_${request.metadata.reportMode === 'full' ? '完整版' : '精简版'}`
|
||||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||||
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) }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
-245
@@ -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|<appmsg\b|<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',
|
||||||
@@ -363,7 +157,7 @@ app.whenReady().then(() => {
|
|||||||
) => {
|
) => {
|
||||||
// @ts-ignore: vite env
|
// @ts-ignore: vite env
|
||||||
const apiKey = options?.apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY
|
const apiKey = options?.apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY
|
||||||
const model = options?.model || import.meta.env.VITE_AI_MODEL || 'deepseek-chat'
|
const model = options?.model || import.meta.env.VITE_AI_MODEL || 'deepseek-v4-flash'
|
||||||
const baseURL =
|
const baseURL =
|
||||||
options?.baseURL || import.meta.env.VITE_AI_BASE_URL || 'https://api.deepseek.com'
|
options?.baseURL || import.meta.env.VITE_AI_BASE_URL || 'https://api.deepseek.com'
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+28
-10
@@ -16,17 +16,35 @@ export interface DatabaseKeyResult {
|
|||||||
|
|
||||||
export class KeyServiceMac {
|
export class KeyServiceMac {
|
||||||
private getHelperPath(): string {
|
private getHelperPath(): string {
|
||||||
const candidates = app.isPackaged
|
// 多 candidate fallback:覆盖 extraResources、asarUnpack、dev 三种场景
|
||||||
? [
|
// (extraResources → Contents/Resources/resources/;asarUnpack 同路径;dev → cwd 或 app.getAppPath)
|
||||||
path.join(process.resourcesPath, 'resources', 'xkey_helper'),
|
const candidates = [
|
||||||
path.join(process.resourcesPath, 'xkey_helper')
|
// 1) extraResources 标准位置(electron-builder.yml 配的就是这个)
|
||||||
]
|
path.join(process.resourcesPath, 'resources', 'xkey_helper'),
|
||||||
: [
|
// 2) process.resourcesPath 直接(防止 extraResources 没复制成功)
|
||||||
path.join(process.cwd(), 'resources', 'xkey_helper'),
|
path.join(process.resourcesPath, 'xkey_helper'),
|
||||||
path.join(app.getAppPath(), 'resources', 'xkey_helper')
|
// 3) asarUnpack 路径(如果在 asar 内的 resources/ 被解包到 app.asar.unpacked)
|
||||||
]
|
path.join(app.getAppPath(), 'app.asar.unpacked', 'resources', 'xkey_helper'),
|
||||||
|
// 4) dev 模式 + 打包后某些版本 app.getAppPath() 也指向 .app 根目录
|
||||||
|
path.join(app.getAppPath(), 'resources', 'xkey_helper'),
|
||||||
|
// 5) dev 模式:cwd
|
||||||
|
path.join(process.cwd(), 'resources', 'xkey_helper')
|
||||||
|
].filter((p, idx, arr) => arr.indexOf(p) === idx) // 去重
|
||||||
|
|
||||||
|
// 诊断:即使命中也打 log,方便排查"装了但找不到"的问题(translocation / quarantine)
|
||||||
|
const statusList = candidates.map((candidate) => ({
|
||||||
|
path: candidate,
|
||||||
|
exists: fs.existsSync(candidate)
|
||||||
|
}))
|
||||||
|
console.log('[KeyServiceMac] xkey_helper candidates:', JSON.stringify(statusList))
|
||||||
const helperPath = candidates.find((candidate) => fs.existsSync(candidate))
|
const helperPath = candidates.find((candidate) => fs.existsSync(candidate))
|
||||||
if (!helperPath) throw new Error('找不到 xkey_helper')
|
if (!helperPath) {
|
||||||
|
throw new Error(
|
||||||
|
`找不到 xkey_helper(尝试 ${candidates.length} 个路径;` +
|
||||||
|
` app.isPackaged=${app.isPackaged} resourcesPath=${process.resourcesPath} ` +
|
||||||
|
` appPath=${app.getAppPath()} cwd=${process.cwd()})`
|
||||||
|
)
|
||||||
|
}
|
||||||
return helperPath
|
return helperPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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|<appmsg\b|<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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
Vendored
+62
@@ -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
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GROUP_REPORT_SYSTEM_PROMPT,
|
GROUP_REPORT_SYSTEM_PROMPT,
|
||||||
parseGroupDailyReport
|
parseGroupDailyReport
|
||||||
} from '../utils/group-report'
|
} from '../utils/group-report'
|
||||||
|
import type { ReportMode } from '../../../shared/group-report'
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
contact: Contact | null
|
contact: Contact | null
|
||||||
@@ -81,9 +82,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const [baseURL, setBaseURL] = useState(
|
const [baseURL, setBaseURL] = useState(
|
||||||
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
|
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
|
||||||
)
|
)
|
||||||
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
|
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-v4-flash')
|
||||||
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
||||||
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
||||||
|
const [reportMode, setReportMode] = useState<ReportMode>(
|
||||||
|
() => (localStorage.getItem('group_report_mode') as ReportMode) || 'compact'
|
||||||
|
)
|
||||||
|
|
||||||
const handleSaveSettings = (): void => {
|
const handleSaveSettings = (): void => {
|
||||||
if (!summaryMessageTypes.length) {
|
if (!summaryMessageTypes.length) {
|
||||||
@@ -93,6 +97,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
localStorage.setItem('ai_api_key', apiKey)
|
localStorage.setItem('ai_api_key', apiKey)
|
||||||
localStorage.setItem('ai_base_url', baseURL)
|
localStorage.setItem('ai_base_url', baseURL)
|
||||||
localStorage.setItem('ai_model', model)
|
localStorage.setItem('ai_model', model)
|
||||||
|
localStorage.setItem('group_report_mode', reportMode)
|
||||||
setShowSettingsModal(false)
|
setShowSettingsModal(false)
|
||||||
AIChat()
|
AIChat()
|
||||||
}
|
}
|
||||||
@@ -240,7 +245,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
|
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
|
||||||
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
|
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
|
||||||
|
|
||||||
const input = buildGroupReportInput(reportMessages, contact, isGroupChat)
|
const input = await buildGroupReportInput(reportMessages, contact, isGroupChat, reportMode)
|
||||||
console.log('🚀 ~ AIChat ~ input:', input)
|
console.log('🚀 ~ AIChat ~ input:', input)
|
||||||
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
|
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
|
||||||
const result = await window.api.aiChat(
|
const result = await window.api.aiChat(
|
||||||
@@ -252,7 +257,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||||
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
|
const report = parseGroupDailyReport(
|
||||||
|
result.data,
|
||||||
|
input.topSpeakers,
|
||||||
|
input.activeTimeline,
|
||||||
|
input.voiceLeaderboard,
|
||||||
|
input.metadata,
|
||||||
|
input.media
|
||||||
|
)
|
||||||
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
|
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
|
||||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||||
throw new Error(exported.error || '日报文件生成失败')
|
throw new Error(exported.error || '日报文件生成失败')
|
||||||
@@ -548,6 +560,31 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ai-filter-section">
|
||||||
|
<div className="ai-filter-label">报告模式</div>
|
||||||
|
<div className="ai-date-options">
|
||||||
|
<label className={reportMode === 'compact' ? 'selected' : ''}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="report-mode"
|
||||||
|
value="compact"
|
||||||
|
checked={reportMode === 'compact'}
|
||||||
|
onChange={() => setReportMode('compact')}
|
||||||
|
/>
|
||||||
|
精简版(推荐)
|
||||||
|
</label>
|
||||||
|
<label className={reportMode === 'full' ? 'selected' : ''}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="report-mode"
|
||||||
|
value="full"
|
||||||
|
checked={reportMode === 'full'}
|
||||||
|
onChange={() => setReportMode('full')}
|
||||||
|
/>
|
||||||
|
完整版
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="ai-filter-section">
|
<div className="ai-filter-section">
|
||||||
<div className="ai-filter-label">消息类型</div>
|
<div className="ai-filter-label">消息类型</div>
|
||||||
<div className="ai-type-options">
|
<div className="ai-type-options">
|
||||||
@@ -570,7 +607,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
style={{ width: '100%', padding: '8px' }}
|
style={{ width: '100%', padding: '8px' }}
|
||||||
>
|
>
|
||||||
<option value="deepseek-chat">DeepSeek Chat</option>
|
<option value="deepseek-v4-pro">DeepSeek V4 Pro</option>
|
||||||
|
<option value="deepseek-v4-flash">DeepSeek V4 Flash</option>
|
||||||
<option value="gpt-4o">GPT-4o</option>
|
<option value="gpt-4o">GPT-4o</option>
|
||||||
<option value="gpt-4o-mini">GPT-4o Mini</option>
|
<option value="gpt-4o-mini">GPT-4o Mini</option>
|
||||||
<option value="gpt-4-turbo">GPT-4 Turbo</option>
|
<option value="gpt-4-turbo">GPT-4 Turbo</option>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
import { Contact, Message } from '../../../shared/types'
|
||||||
|
import {
|
||||||
|
GroupDailyReport,
|
||||||
|
GroupReportMetadata,
|
||||||
|
ReportFunBadge,
|
||||||
|
ReportMediaGalleryItem,
|
||||||
|
ReportMode,
|
||||||
|
ReportSpeakerRank,
|
||||||
|
ReportVoiceHighlight,
|
||||||
|
ReportVoiceLeaderboardItem
|
||||||
|
} from '../../../shared/group-report'
|
||||||
|
|
||||||
|
export interface GroupReportTranscriptRow {
|
||||||
|
id: string
|
||||||
|
datetime: string
|
||||||
|
timestamp: number
|
||||||
|
sender: string
|
||||||
|
content: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupReportFactsSnapshot {
|
||||||
|
metadata: GroupReportMetadata
|
||||||
|
transcriptRows: GroupReportTranscriptRow[]
|
||||||
|
topSpeakers: ReportSpeakerRank[]
|
||||||
|
activeTimeline: string
|
||||||
|
media: GroupDailyReport['media']
|
||||||
|
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||||
|
factsPrompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isInternalIdentifier = (value: string): boolean =>
|
||||||
|
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
|
||||||
|
|
||||||
|
export const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
|
||||||
|
if (message.from === 'assistant') {
|
||||||
|
const ownGroupNickname = message.name?.trim()
|
||||||
|
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
|
||||||
|
return ownGroupNickname
|
||||||
|
}
|
||||||
|
return '我'
|
||||||
|
}
|
||||||
|
const candidate = isGroup ? message.name : contact?.m_nsNickName
|
||||||
|
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
export const summaryContent = (message: Message): string => {
|
||||||
|
const data = message.contentData
|
||||||
|
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'image':
|
||||||
|
return '[图片]'
|
||||||
|
case 'sticker':
|
||||||
|
return '[表情]'
|
||||||
|
case 'voice':
|
||||||
|
return `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
||||||
|
case 'share':
|
||||||
|
return `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
|
||||||
|
case 'quote': {
|
||||||
|
const reply = data.title || data.content || message.content || '[回复]'
|
||||||
|
const quotedSender =
|
||||||
|
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
|
||||||
|
return `${reply}(引用 ${quotedSender}:${data.quotedContent || `[引用${data.quotedType || '消息'}]`})`
|
||||||
|
}
|
||||||
|
case 'location':
|
||||||
|
return `[位置] ${data.poiname || data.label || '位置消息'}`
|
||||||
|
case 'card':
|
||||||
|
return `[名片] ${data.nickname || '微信名片'}`
|
||||||
|
case 'voip':
|
||||||
|
return `[通话] ${data.status}${data.duration ? `,${data.duration}秒` : ''}`
|
||||||
|
case 'system':
|
||||||
|
case 'text':
|
||||||
|
return data.content
|
||||||
|
case 'unknown':
|
||||||
|
return `[${message.type || '未知消息'}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${message.type || '消息'}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseTimestamp = (message: Message): number => {
|
||||||
|
const value = new Date(message.datetime).getTime()
|
||||||
|
return Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localDate = (timestamp: number): string => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localTime = (timestamp: number): string =>
|
||||||
|
new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveVoiceDuration = (message: Message): number => {
|
||||||
|
const fromData = message.contentData?.type === 'voice' ? message.contentData.duration : undefined
|
||||||
|
return Math.max(0, Number(fromData ?? message.voiceDuration ?? 0) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncate = (value: string, max = 48): string =>
|
||||||
|
value.length > max ? `${value.slice(0, max - 1)}…` : value
|
||||||
|
|
||||||
|
const buildImageContext = (
|
||||||
|
messages: Message[],
|
||||||
|
index: number,
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean
|
||||||
|
): {
|
||||||
|
note: string
|
||||||
|
stats: string
|
||||||
|
responseCount: number
|
||||||
|
participantCount: number
|
||||||
|
snippets: string[]
|
||||||
|
} => {
|
||||||
|
const baseTime = parseTimestamp(messages[index])
|
||||||
|
const participants = new Set<string>()
|
||||||
|
const snippets: string[] = []
|
||||||
|
let responseCount = 0
|
||||||
|
|
||||||
|
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
|
||||||
|
const candidate = messages[offset]
|
||||||
|
const candidateTime = parseTimestamp(candidate)
|
||||||
|
if (baseTime && candidateTime && candidateTime - baseTime > 20 * 60 * 1000) break
|
||||||
|
if (candidate.type === '系统消息' || candidate.from === 'system') continue
|
||||||
|
|
||||||
|
const sender = summarySender(candidate, contact, isGroup)
|
||||||
|
const sameSender = sender === summarySender(messages[index], contact, isGroup)
|
||||||
|
const content = summaryContent(candidate)
|
||||||
|
if (!sameSender) {
|
||||||
|
responseCount += 1
|
||||||
|
participants.add(sender)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
snippets.length < 3 &&
|
||||||
|
!content.startsWith('[图片]') &&
|
||||||
|
!content.startsWith('[表情]') &&
|
||||||
|
!content.startsWith('[语音')
|
||||||
|
) {
|
||||||
|
snippets.push(truncate(content, 28))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = snippets.length
|
||||||
|
? `图片发出后,群里接着聊到:${snippets.join(' / ')}`
|
||||||
|
: responseCount > 0
|
||||||
|
? '图片发出后引发了一波接续讨论。'
|
||||||
|
: '这张图片更多像是一次轻量分享,没有形成长链路讨论。'
|
||||||
|
|
||||||
|
const statsParts: string[] = []
|
||||||
|
if (responseCount > 0) statsParts.push(`${responseCount} 条后续消息`)
|
||||||
|
if (participants.size > 0) statsParts.push(`${participants.size} 人接话`)
|
||||||
|
if (!statsParts.length) statsParts.push('讨论热度较低')
|
||||||
|
|
||||||
|
return {
|
||||||
|
note,
|
||||||
|
stats: statsParts.join(' · '),
|
||||||
|
responseCount,
|
||||||
|
participantCount: participants.size,
|
||||||
|
snippets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMediaSection = async (
|
||||||
|
messages: Message[],
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean,
|
||||||
|
topSpeakersMap: Map<string, number>
|
||||||
|
): Promise<{
|
||||||
|
media: GroupDailyReport['media']
|
||||||
|
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||||
|
}> => {
|
||||||
|
const rawImageCandidates = messages
|
||||||
|
.map((message, index) => {
|
||||||
|
if (message.contentData?.type !== 'image') return null
|
||||||
|
const sender = summarySender(message, contact, isGroup)
|
||||||
|
const context = buildImageContext(messages, index, contact, isGroup)
|
||||||
|
return {
|
||||||
|
sourceMessageIds: [message.id],
|
||||||
|
md5: message.contentData.md5,
|
||||||
|
datName: message.contentData.datName,
|
||||||
|
sessionId: message.sessionId,
|
||||||
|
sender,
|
||||||
|
time: localTime(parseTimestamp(message)),
|
||||||
|
note: context.note,
|
||||||
|
stats: context.stats,
|
||||||
|
replyCount: context.responseCount,
|
||||||
|
score: context.responseCount * 3 + context.participantCount * 2 + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||||
|
.sort((left, right) => right.score - left.score)
|
||||||
|
.slice(0, 6)
|
||||||
|
|
||||||
|
const imageCandidates = await Promise.all(
|
||||||
|
rawImageCandidates.map(async (item) => {
|
||||||
|
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
|
||||||
|
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
||||||
|
return {
|
||||||
|
sender: item.sender,
|
||||||
|
time: item.time,
|
||||||
|
imageUrl: result.data,
|
||||||
|
note: item.note,
|
||||||
|
stats: item.stats,
|
||||||
|
inferenceLabel: '基于图片后的聊天上下文推断',
|
||||||
|
sourceMessageIds: item.sourceMessageIds,
|
||||||
|
replyCount: item.replyCount,
|
||||||
|
score: item.score
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const gallery: ReportMediaGalleryItem[] = imageCandidates
|
||||||
|
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||||
|
.sort((left, right) => right.score - left.score)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(({ score: _score, ...item }) => item)
|
||||||
|
|
||||||
|
const voiceMessages = messages
|
||||||
|
.filter((message) => message.contentData?.type === 'voice')
|
||||||
|
.map((message) => ({
|
||||||
|
sender: summarySender(message, contact, isGroup),
|
||||||
|
duration: resolveVoiceDuration(message),
|
||||||
|
time: localTime(parseTimestamp(message))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const voiceTotals = new Map<string, { count: number; duration: number }>()
|
||||||
|
for (const item of voiceMessages) {
|
||||||
|
const current = voiceTotals.get(item.sender) || { count: 0, duration: 0 }
|
||||||
|
current.count += 1
|
||||||
|
current.duration += item.duration
|
||||||
|
voiceTotals.set(item.sender, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceLeaderboard: ReportVoiceLeaderboardItem[] = Array.from(voiceTotals.entries())
|
||||||
|
.map(([sender, value]) => ({
|
||||||
|
sender,
|
||||||
|
count: value.count,
|
||||||
|
durationSec: value.duration
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.durationSec - left.durationSec || right.count - left.count)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
let bestStreak: { sender: string; count: number; duration: number; time: string } | null = null
|
||||||
|
let currentStreak: { sender: string; count: number; duration: number; time: string } | null = null
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.contentData?.type !== 'voice') {
|
||||||
|
currentStreak = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const sender = summarySender(message, contact, isGroup)
|
||||||
|
const duration = resolveVoiceDuration(message)
|
||||||
|
const time = localTime(parseTimestamp(message))
|
||||||
|
if (currentStreak && currentStreak.sender === sender) {
|
||||||
|
currentStreak.count += 1
|
||||||
|
currentStreak.duration += duration
|
||||||
|
} else {
|
||||||
|
currentStreak = { sender, count: 1, duration, time }
|
||||||
|
}
|
||||||
|
if (!bestStreak || currentStreak.count > bestStreak.count) {
|
||||||
|
bestStreak = { ...currentStreak }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceHighlights: ReportVoiceHighlight[] = []
|
||||||
|
if (voiceLeaderboard[0]) {
|
||||||
|
voiceHighlights.push({
|
||||||
|
title: '语音输出王',
|
||||||
|
sender: voiceLeaderboard[0].sender,
|
||||||
|
note: `共发送 ${voiceLeaderboard[0].count} 条语音,累计 ${voiceLeaderboard[0].durationSec} 秒。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (bestStreak && bestStreak.count >= 2) {
|
||||||
|
voiceHighlights.push({
|
||||||
|
title: '连续发言时刻',
|
||||||
|
sender: bestStreak.sender,
|
||||||
|
note: `${bestStreak.time} 连发 ${bestStreak.count} 条语音,共 ${bestStreak.duration} 秒。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const funBadges: ReportFunBadge[] = []
|
||||||
|
const topSpeaker = Array.from(topSpeakersMap.entries()).sort((left, right) => right[1] - left[1])[0]
|
||||||
|
if (topSpeaker) {
|
||||||
|
funBadges.push({
|
||||||
|
title: '高能输出王',
|
||||||
|
owner: topSpeaker[0],
|
||||||
|
note: `今天一共发了 ${topSpeaker[1]} 条消息。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (gallery[0]) {
|
||||||
|
funBadges.push({
|
||||||
|
title: '图片话题王',
|
||||||
|
owner: gallery[0].sender,
|
||||||
|
note: `${gallery[0].time} 的图片带动了最明显的一轮讨论。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (voiceLeaderboard[0]) {
|
||||||
|
funBadges.push({
|
||||||
|
title: '语音麦霸',
|
||||||
|
owner: voiceLeaderboard[0].sender,
|
||||||
|
note: `语音总时长暂居第一,适合放进“今日声音档案”。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
media: {
|
||||||
|
gallery,
|
||||||
|
voiceHighlights: voiceHighlights.slice(0, 2),
|
||||||
|
funBadges: funBadges.slice(0, 3)
|
||||||
|
},
|
||||||
|
voiceLeaderboard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectQuestionCandidates = (
|
||||||
|
messages: Message[],
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean
|
||||||
|
): string[] =>
|
||||||
|
messages
|
||||||
|
.map((message) => ({
|
||||||
|
id: message.id,
|
||||||
|
sender: summarySender(message, contact, isGroup),
|
||||||
|
content: summaryContent(message)
|
||||||
|
}))
|
||||||
|
.filter((item) => /[??]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么'))
|
||||||
|
.slice(-6)
|
||||||
|
.map((item) => `${item.sender}(${item.id}):${truncate(item.content, 32)}`)
|
||||||
|
|
||||||
|
const collectReplyFacts = (messages: Message[], contact: Contact | null, isGroup: boolean): string[] =>
|
||||||
|
messages
|
||||||
|
.filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((message) => {
|
||||||
|
const sender = summarySender(message, contact, isGroup)
|
||||||
|
const quotedSender =
|
||||||
|
message.contentData?.type === 'quote' && message.contentData.quotedSender
|
||||||
|
? message.contentData.quotedSender
|
||||||
|
: '群成员'
|
||||||
|
return `${sender} 回复了 ${quotedSender}`
|
||||||
|
})
|
||||||
|
|
||||||
|
export const buildGroupReportFacts = async (
|
||||||
|
messages: Message[],
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean,
|
||||||
|
reportMode: ReportMode
|
||||||
|
): Promise<GroupReportFactsSnapshot> => {
|
||||||
|
const transcriptRows = messages.map((message) => ({
|
||||||
|
id: message.id,
|
||||||
|
datetime: message.datetime,
|
||||||
|
timestamp: parseTimestamp(message),
|
||||||
|
sender: summarySender(message, contact, isGroup),
|
||||||
|
content: summaryContent(message),
|
||||||
|
avatar: message.img
|
||||||
|
}))
|
||||||
|
|
||||||
|
let firstTimestamp = Number.POSITIVE_INFINITY
|
||||||
|
let lastTimestamp = Number.NEGATIVE_INFINITY
|
||||||
|
for (const row of transcriptRows) {
|
||||||
|
if (!Number.isFinite(row.timestamp)) continue
|
||||||
|
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
|
||||||
|
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
|
||||||
|
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
|
||||||
|
|
||||||
|
const speakerCounts = new Map<string, number>()
|
||||||
|
const hourCounts = new Map<number, number>()
|
||||||
|
const avatars: Record<string, string | undefined> = {}
|
||||||
|
let imageCount = 0
|
||||||
|
let stickerCount = 0
|
||||||
|
let voiceCount = 0
|
||||||
|
let voiceDurationSec = 0
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const sender = summarySender(message, contact, isGroup)
|
||||||
|
const timestamp = parseTimestamp(message)
|
||||||
|
speakerCounts.set(sender, (speakerCounts.get(sender) || 0) + 1)
|
||||||
|
if (Number.isFinite(timestamp)) {
|
||||||
|
const hour = new Date(timestamp).getHours()
|
||||||
|
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
|
||||||
|
}
|
||||||
|
if (message.img && !avatars[sender]) avatars[sender] = message.img
|
||||||
|
if (message.contentData?.type === 'image') imageCount += 1
|
||||||
|
if (message.contentData?.type === 'sticker') stickerCount += 1
|
||||||
|
if (message.contentData?.type === 'voice') {
|
||||||
|
voiceCount += 1
|
||||||
|
voiceDurationSec += resolveVoiceDuration(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
|
||||||
|
.sort((left, right) => right.count - left.count)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
|
||||||
|
.sort((left, right) => right.count - left.count)
|
||||||
|
.slice(0, 4)
|
||||||
|
.sort((left, right) => left.hour - right.hour)
|
||||||
|
.map(
|
||||||
|
({ hour, count }) =>
|
||||||
|
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59(${count}条)`
|
||||||
|
)
|
||||||
|
.join('、')
|
||||||
|
|
||||||
|
const startDate = localDate(firstTimestamp)
|
||||||
|
const endDate = localDate(lastTimestamp)
|
||||||
|
const sameDay = startDate === endDate
|
||||||
|
const dateRange = sameDay
|
||||||
|
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
|
||||||
|
: `${startDate} ${localTime(firstTimestamp)} 至 ${endDate} ${localTime(lastTimestamp)}`
|
||||||
|
const durationMs = Math.max(0, lastTimestamp - firstTimestamp)
|
||||||
|
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 groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
|
||||||
|
const metadata: GroupReportMetadata = {
|
||||||
|
groupName,
|
||||||
|
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
|
||||||
|
dateRange,
|
||||||
|
messageCount: transcriptRows.length,
|
||||||
|
activeUsers: speakerCounts.size,
|
||||||
|
imageCount,
|
||||||
|
voiceCount,
|
||||||
|
stickerCount,
|
||||||
|
mediaMessageCount: imageCount + voiceCount + stickerCount,
|
||||||
|
timeSpan,
|
||||||
|
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||||
|
recordNote: `基于当前已加载的 ${transcriptRows.length} 条记录`,
|
||||||
|
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。',
|
||||||
|
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
|
||||||
|
avatars,
|
||||||
|
reportMode
|
||||||
|
}
|
||||||
|
|
||||||
|
const { media, voiceLeaderboard } = await buildMediaSection(messages, contact, isGroup, speakerCounts)
|
||||||
|
|
||||||
|
const factsPrompt = [
|
||||||
|
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
|
||||||
|
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
|
||||||
|
activeTimeline ? `活跃时段:${activeTimeline}` : '',
|
||||||
|
media.gallery.length
|
||||||
|
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats})`).join(';')}`
|
||||||
|
: '',
|
||||||
|
voiceLeaderboard.length
|
||||||
|
? `语音榜:${voiceLeaderboard
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => `${item.sender} ${item.count} 条 / ${item.durationSec} 秒`)
|
||||||
|
.join(';')}`
|
||||||
|
: '',
|
||||||
|
collectQuestionCandidates(messages, contact, isGroup).length
|
||||||
|
? `疑似待跟进问题:${collectQuestionCandidates(messages, contact, isGroup).join(';')}`
|
||||||
|
: '',
|
||||||
|
collectReplyFacts(messages, contact, isGroup).length
|
||||||
|
? `回复关系样本:${collectReplyFacts(messages, contact, isGroup).join(';')}`
|
||||||
|
: ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
transcriptRows,
|
||||||
|
topSpeakers,
|
||||||
|
activeTimeline,
|
||||||
|
media,
|
||||||
|
voiceLeaderboard,
|
||||||
|
factsPrompt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,204 +1,206 @@
|
|||||||
import { Contact, Message } from '../../../shared/types'
|
|
||||||
import {
|
import {
|
||||||
GroupDailyReport,
|
GroupDailyReport,
|
||||||
GroupReportMetadata,
|
GroupReportMetadata,
|
||||||
ReportHeat,
|
ReportHeat,
|
||||||
ReportImportantMessage,
|
ReportImportantMessage,
|
||||||
|
ReportMode,
|
||||||
|
ReportParticipantChain,
|
||||||
ReportQuestionAnswer,
|
ReportQuestionAnswer,
|
||||||
ReportQuote,
|
ReportQuote,
|
||||||
ReportResource,
|
ReportResource,
|
||||||
|
ReportReversal,
|
||||||
|
ReportSectionKey,
|
||||||
|
ReportSectionMeta,
|
||||||
ReportSpeakerRank,
|
ReportSpeakerRank,
|
||||||
ReportTopic
|
ReportStoryline,
|
||||||
|
ReportTodoItem,
|
||||||
|
ReportTopic,
|
||||||
|
ReportUnresolvedItem,
|
||||||
|
ReportVoiceLeaderboardItem
|
||||||
} from '../../../shared/group-report'
|
} from '../../../shared/group-report'
|
||||||
|
import { Contact, Message } from '../../../shared/types'
|
||||||
|
import { buildGroupReportFacts } from './group-report-facts'
|
||||||
|
|
||||||
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
|
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
|
||||||
|
|
||||||
原则:
|
总原则:
|
||||||
1. 不得编造聊天中没有的事实、结论、参与者或链接内容。
|
1. 不得编造聊天中没有的事实、结论、参与者或链接内容。
|
||||||
2. 仅使用输入中的昵称,不输出 wxid、微信 ID、会话 ID 等内部标识。
|
2. 仅使用输入中的昵称,不输出 wxid、微信 ID、会话 ID 等内部标识。
|
||||||
3. 图片、表情、语音、视频或链接内容不可见时,仅标注消息类型,不要猜测。
|
3. 图片、表情、语音、视频或链接内容不可见时,只能描述消息类型、互动效果和上下文,不能猜图片具体内容。
|
||||||
4. 摘要说明发生了什么、大家如何回应、最后形成什么结论或氛围。
|
4. 先合并同一事件,再按重要性排序,避免一个事件在多个模块大段重复出现。
|
||||||
5. 语气准确、轻巧、有信息密度,避免侮辱性和歧视性评价。
|
5. 优先保留结论、待办、未解决问题、明确通知,再保留趣味内容。
|
||||||
6. 没有实际内容的可选栏目输出空数组,不要凑数。
|
6. 负责人、截止日期、图片内容必须有聊天证据;没有证据就返回 null 或省略。
|
||||||
7. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
|
7. 不为了填满模板而生成内容,没有就输出空数组。
|
||||||
|
8. 所有候选条目尽量返回 sourceMessageIds,便于程序去重和追溯。
|
||||||
|
9. 精简版面向 30 秒阅读,摘要必须短;完整版可以保留更多候选项。
|
||||||
|
10. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
|
||||||
|
|
||||||
JSON 结构必须为:
|
JSON 结构必须为:
|
||||||
{
|
{
|
||||||
"overview": "1至2句整体讨论风格与氛围",
|
"overview": "1至2句整体讨论风格与氛围",
|
||||||
|
"hero": {
|
||||||
|
"headline": "一句抓重点的日报标题",
|
||||||
|
"summary": "一句总结今天发生了什么,最多3行",
|
||||||
|
"keyTakeaway": "最重要结论,没有可空",
|
||||||
|
"pendingNote": "最值得跟进的一项,没有可空",
|
||||||
|
"statusLine": "例如:今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决"
|
||||||
|
},
|
||||||
"topics": [{
|
"topics": [{
|
||||||
"title": "话题标题",
|
"title": "话题标题",
|
||||||
"timeRange": "HH:mm-HH:mm",
|
"timeRange": "HH:mm-HH:mm",
|
||||||
"heat": "高|中|低",
|
"heat": "高|中|低",
|
||||||
"participants": ["昵称"],
|
"participants": ["昵称"],
|
||||||
"summary": "话题摘要",
|
"summary": "100字以内",
|
||||||
"conclusion": "结论或氛围",
|
"conclusion": "可选简短结论",
|
||||||
"keywords": ["关键词"]
|
"conclusions": [{"text":"关键结论","sourceMessageIds":["消息ID"]}],
|
||||||
|
"keywords": ["关键词"],
|
||||||
|
"sourceMessageIds": ["消息ID"]
|
||||||
}],
|
}],
|
||||||
"resources": [{"title":"资源名","description":"用途或内容","sender":"昵称"}],
|
"resources": [{"title":"资源名","description":"用途或内容","sender":"昵称","sourceMessageIds":["消息ID"]}],
|
||||||
"importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要"}],
|
"importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要","sourceMessageIds":["消息ID"],"importance":0.95,"confidence":0.9}],
|
||||||
"quotes": [{"messages":[{"sender":"昵称","content":"简短原话"}],"note":"点评"}],
|
"quotes": [{"messages":[{"sender":"昵称","content":"简短原话","sourceMessageId":"消息ID"}],"note":"为什么好笑或值得看","sourceMessageIds":["消息ID"],"importance":0.8,"confidence":0.8}],
|
||||||
"qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称"}],
|
"qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称","sourceMessageIds":["消息ID"]}],
|
||||||
|
"todos": [{"task":"待办事项","owner":"负责人或null","deadline":"截止时间或null","topic":"来源话题或null","note":"补充说明","sourceMessageIds":["消息ID"],"importance":0.95,"confidence":0.85}],
|
||||||
|
"unresolved": [{"question":"待跟进的问题","owner":"提问者或相关人","status":"待跟进|暂未回答|进行中","note":"为什么还没结束","lastDiscussedAt":"HH:mm 或 null","sourceMessageIds":["消息ID"],"importance":0.9,"confidence":0.8}],
|
||||||
|
"storylines": [{"title":"剧情线标题","stages":[{"time":"HH:mm","event":"发生了什么","sourceMessageIds":["消息ID"]}],"result":"结果","sourceMessageIds":["消息ID"]}],
|
||||||
|
"reversals": [{"topic":"发生反转的话题","initialView":"最初判断","finalView":"最终判断","note":"为什么反转","sourceMessageIds":["消息ID"]}],
|
||||||
|
"participantChains": [{"topic":"话题名","chain":["A 提出","B 补充","C 收尾"],"note":"链路说明","sourceMessageIds":["消息ID"]}],
|
||||||
"keywords": ["关键词"]
|
"keywords": ["关键词"]
|
||||||
}
|
}
|
||||||
|
|
||||||
topics 提取 3 至 7 个,参与者最多 5 人,quotes 最多 3 组,keywords 输出 8 至 15 个。`
|
额外要求:
|
||||||
|
- 精简版:热点最多4个、重要消息最多4条、待办最多5条、未解决最多3条、名场面最多2组、每组最多4条、关键词最多12个。
|
||||||
const isInternalIdentifier = (value: string): boolean =>
|
- 完整版:可以保留更多候选项,但仍要去重和排序。
|
||||||
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
|
- 如果某个字段不确定,请返回 null 或空数组,不要猜。`
|
||||||
|
|
||||||
const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
|
|
||||||
if (message.from === 'assistant') {
|
|
||||||
const ownGroupNickname = message.name?.trim()
|
|
||||||
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
|
|
||||||
return ownGroupNickname
|
|
||||||
}
|
|
||||||
return '我'
|
|
||||||
}
|
|
||||||
const candidate = isGroup ? message.name : contact?.m_nsNickName
|
|
||||||
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
const summaryContent = (message: Message): string => {
|
|
||||||
const data = message.contentData
|
|
||||||
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
|
|
||||||
|
|
||||||
switch (data.type) {
|
|
||||||
case 'image':
|
|
||||||
return '[图片]'
|
|
||||||
case 'sticker':
|
|
||||||
return '[表情]'
|
|
||||||
case 'voice':
|
|
||||||
return `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
|
||||||
case 'share':
|
|
||||||
return `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
|
|
||||||
case 'quote': {
|
|
||||||
const reply = data.title || data.content || message.content || '[回复]'
|
|
||||||
const quotedSender =
|
|
||||||
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
|
|
||||||
return `${reply}(引用 ${quotedSender}:${data.quotedContent || `[引用${data.quotedType || '消息'}]`})`
|
|
||||||
}
|
|
||||||
case 'location':
|
|
||||||
return `[位置] ${data.poiname || data.label || '位置消息'}`
|
|
||||||
case 'card':
|
|
||||||
return `[名片] ${data.nickname || '微信名片'}`
|
|
||||||
case 'voip':
|
|
||||||
return `[通话] ${data.status}${data.duration ? `,${data.duration}秒` : ''}`
|
|
||||||
case 'system':
|
|
||||||
case 'text':
|
|
||||||
return data.content
|
|
||||||
case 'unknown':
|
|
||||||
return `[${message.type || '未知消息'}]`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `[${message.type || '消息'}]`
|
|
||||||
}
|
|
||||||
|
|
||||||
const localDate = (timestamp: number): string => {
|
|
||||||
const date = new Date(timestamp)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const localTime = (timestamp: number): string =>
|
|
||||||
new Date(timestamp).toLocaleTimeString('zh-CN', {
|
|
||||||
hour12: false,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface GroupReportInput {
|
export interface GroupReportInput {
|
||||||
prompt: string
|
prompt: string
|
||||||
metadata: GroupReportMetadata
|
metadata: GroupReportMetadata
|
||||||
topSpeakers: ReportSpeakerRank[]
|
topSpeakers: ReportSpeakerRank[]
|
||||||
activeTimeline: string
|
activeTimeline: string
|
||||||
|
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||||
|
media: GroupDailyReport['media']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildGroupReportInput = (
|
const REPORT_MODE_LABEL: Record<ReportMode, string> = {
|
||||||
|
compact: '精简版(推荐)',
|
||||||
|
full: '完整版'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportModeConfig {
|
||||||
|
maxTopics: number
|
||||||
|
maxImportantMessages: number
|
||||||
|
maxTodos: number
|
||||||
|
maxUnresolved: number
|
||||||
|
maxQuotes: number
|
||||||
|
maxQuoteMessages: number
|
||||||
|
maxKeywords: number
|
||||||
|
maxStorylines: number
|
||||||
|
maxReversals: number
|
||||||
|
maxChains: number
|
||||||
|
maxGallery: number
|
||||||
|
maxVoiceHighlights: number
|
||||||
|
maxBadges: number
|
||||||
|
maxResources: number
|
||||||
|
maxQa: number
|
||||||
|
topicSummaryLength: number
|
||||||
|
noteLength: number
|
||||||
|
enabledSections: ReportSectionKey[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
|
||||||
|
compact: {
|
||||||
|
maxTopics: 3,
|
||||||
|
maxImportantMessages: 3,
|
||||||
|
maxTodos: 4,
|
||||||
|
maxUnresolved: 3,
|
||||||
|
maxQuotes: 1,
|
||||||
|
maxQuoteMessages: 4,
|
||||||
|
maxKeywords: 12,
|
||||||
|
maxStorylines: 0,
|
||||||
|
maxReversals: 0,
|
||||||
|
maxChains: 0,
|
||||||
|
maxGallery: 0,
|
||||||
|
maxVoiceHighlights: 0,
|
||||||
|
maxBadges: 0,
|
||||||
|
maxResources: 0,
|
||||||
|
maxQa: 0,
|
||||||
|
topicSummaryLength: 100,
|
||||||
|
noteLength: 72,
|
||||||
|
enabledSections: ['hero', 'topics', 'importantMessages', 'actions', 'moments', 'analytics', 'keywords']
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
maxTopics: 6,
|
||||||
|
maxImportantMessages: 6,
|
||||||
|
maxTodos: 8,
|
||||||
|
maxUnresolved: 5,
|
||||||
|
maxQuotes: 3,
|
||||||
|
maxQuoteMessages: 5,
|
||||||
|
maxKeywords: 15,
|
||||||
|
maxStorylines: 2,
|
||||||
|
maxReversals: 2,
|
||||||
|
maxChains: 3,
|
||||||
|
maxGallery: 4,
|
||||||
|
maxVoiceHighlights: 2,
|
||||||
|
maxBadges: 3,
|
||||||
|
maxResources: 4,
|
||||||
|
maxQa: 4,
|
||||||
|
topicSummaryLength: 140,
|
||||||
|
noteLength: 96,
|
||||||
|
enabledSections: [
|
||||||
|
'hero',
|
||||||
|
'topics',
|
||||||
|
'importantMessages',
|
||||||
|
'actions',
|
||||||
|
'moments',
|
||||||
|
'analytics',
|
||||||
|
'keywords',
|
||||||
|
'resources',
|
||||||
|
'qa',
|
||||||
|
'storylines',
|
||||||
|
'reversals',
|
||||||
|
'gallery',
|
||||||
|
'voices',
|
||||||
|
'badges',
|
||||||
|
'chains'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildGroupReportInput = async (
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
contact: Contact | null,
|
contact: Contact | null,
|
||||||
isGroup: boolean
|
isGroup: boolean,
|
||||||
): GroupReportInput => {
|
reportMode: ReportMode
|
||||||
const rows = messages.map((message) => ({
|
): Promise<GroupReportInput> => {
|
||||||
datetime: message.datetime,
|
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode)
|
||||||
timestamp: new Date(message.datetime).getTime(),
|
const transcript = facts.transcriptRows
|
||||||
sender: summarySender(message, contact, isGroup),
|
.map((row) => `[${row.id}] ${row.datetime} ${row.sender}:${row.content}`)
|
||||||
content: summaryContent(message),
|
.join('\n')
|
||||||
avatar: message.img
|
|
||||||
}))
|
|
||||||
|
|
||||||
let firstTimestamp = Number.POSITIVE_INFINITY
|
|
||||||
let lastTimestamp = Number.NEGATIVE_INFINITY
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!Number.isFinite(row.timestamp)) continue
|
|
||||||
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
|
|
||||||
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
|
|
||||||
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
|
|
||||||
const speakerCounts = new Map<string, number>()
|
|
||||||
const hourCounts = new Map<number, number>()
|
|
||||||
const avatars: Record<string, string | undefined> = {}
|
|
||||||
for (const row of rows) {
|
|
||||||
speakerCounts.set(row.sender, (speakerCounts.get(row.sender) || 0) + 1)
|
|
||||||
if (Number.isFinite(row.timestamp)) {
|
|
||||||
const hour = new Date(row.timestamp).getHours()
|
|
||||||
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
|
|
||||||
}
|
|
||||||
if (row.avatar && !avatars[row.sender]) avatars[row.sender] = row.avatar
|
|
||||||
}
|
|
||||||
|
|
||||||
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
|
|
||||||
.sort((left, right) => right.count - left.count)
|
|
||||||
.slice(0, 5)
|
|
||||||
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
|
|
||||||
.sort((left, right) => right.count - left.count)
|
|
||||||
.slice(0, 4)
|
|
||||||
.sort((left, right) => left.hour - right.hour)
|
|
||||||
.map(
|
|
||||||
({ hour, count }) =>
|
|
||||||
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59(${count}条)`
|
|
||||||
)
|
|
||||||
.join('、')
|
|
||||||
|
|
||||||
const startDate = localDate(firstTimestamp)
|
|
||||||
const endDate = localDate(lastTimestamp)
|
|
||||||
const sameDay = startDate === endDate
|
|
||||||
const dateRange = sameDay
|
|
||||||
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
|
|
||||||
: `${startDate} ${localTime(firstTimestamp)} 至 ${endDate} ${localTime(lastTimestamp)}`
|
|
||||||
const timeSpan = sameDay
|
|
||||||
? `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 3600000))}小时`
|
|
||||||
: `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 86400000))}天`
|
|
||||||
const contactName = contact?.m_nsNickName || ''
|
|
||||||
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
|
|
||||||
const metadata: GroupReportMetadata = {
|
|
||||||
groupName,
|
|
||||||
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
|
|
||||||
dateRange,
|
|
||||||
messageCount: rows.length,
|
|
||||||
activeUsers: speakerCounts.size,
|
|
||||||
timeSpan,
|
|
||||||
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
|
||||||
recordNote: `基于当前已加载的 ${rows.length} 条记录`,
|
|
||||||
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容仅按类型统计。',
|
|
||||||
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
|
|
||||||
avatars
|
|
||||||
}
|
|
||||||
const transcript = rows.map((row) => `${row.datetime} ${row.sender}:${row.content}`).join('\n')
|
|
||||||
const prompt = `请为以下微信${isGroup ? '群聊' : '对话'}记录生成日报 JSON。
|
const prompt = `请为以下微信${isGroup ? '群聊' : '对话'}记录生成日报 JSON。
|
||||||
|
|
||||||
会话名:${groupName}
|
会话名:${facts.metadata.groupName}
|
||||||
时间范围:${dateRange}
|
时间范围:${facts.metadata.dateRange}
|
||||||
消息数:${rows.length}
|
消息数:${facts.metadata.messageCount}
|
||||||
活跃人数:${speakerCounts.size}
|
活跃人数:${facts.metadata.activeUsers}
|
||||||
|
目标模式:${REPORT_MODE_LABEL[reportMode]}
|
||||||
完整性:仅基于当前应用已加载的记录。
|
完整性:仅基于当前应用已加载的记录。
|
||||||
|
|
||||||
|
媒体与结构化事实(可引用行为,不可猜测图片内容):
|
||||||
|
${facts.factsPrompt || '无'}
|
||||||
|
|
||||||
聊天记录:
|
聊天记录:
|
||||||
${transcript}`
|
${transcript}`
|
||||||
return { prompt, metadata, topSpeakers, activeTimeline }
|
|
||||||
|
return {
|
||||||
|
prompt,
|
||||||
|
metadata: facts.metadata,
|
||||||
|
topSpeakers: facts.topSpeakers,
|
||||||
|
activeTimeline: facts.activeTimeline,
|
||||||
|
voiceLeaderboard: facts.voiceLeaderboard,
|
||||||
|
media: facts.media
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const asObject = (value: unknown): Record<string, unknown> =>
|
const asObject = (value: unknown): Record<string, unknown> =>
|
||||||
@@ -207,9 +209,9 @@ const asObject = (value: unknown): Record<string, unknown> =>
|
|||||||
: {}
|
: {}
|
||||||
|
|
||||||
const asString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
|
const asString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
|
||||||
const asName = (value: unknown): string => {
|
const asNullableString = (value: unknown): string | null => {
|
||||||
const name = asString(value)
|
const result = asString(value)
|
||||||
return name && !isInternalIdentifier(name) ? name : '未命名群成员'
|
return result || null
|
||||||
}
|
}
|
||||||
const asNumber = (value: unknown): number => {
|
const asNumber = (value: unknown): number => {
|
||||||
const parsed = Number(value)
|
const parsed = Number(value)
|
||||||
@@ -219,6 +221,8 @@ const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [
|
|||||||
const asStrings = (value: unknown, limit = 20): string[] =>
|
const asStrings = (value: unknown, limit = 20): string[] =>
|
||||||
asArray(value).map(asString).filter(Boolean).slice(0, limit)
|
asArray(value).map(asString).filter(Boolean).slice(0, limit)
|
||||||
|
|
||||||
|
const normalizeName = (value: unknown): string => asString(value) || '未命名群成员'
|
||||||
|
|
||||||
const normalizeHeat = (value: unknown): ReportHeat => {
|
const normalizeHeat = (value: unknown): ReportHeat => {
|
||||||
const heat = asString(value)
|
const heat = asString(value)
|
||||||
if (heat.includes('高')) return '高'
|
if (heat.includes('高')) return '高'
|
||||||
@@ -226,6 +230,9 @@ const normalizeHeat = (value: unknown): ReportHeat => {
|
|||||||
return '中'
|
return '中'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const truncate = (value: string, max: number): string =>
|
||||||
|
value.length > max ? `${value.slice(0, Math.max(1, max - 1))}…` : value
|
||||||
|
|
||||||
const extractJson = (raw: string): unknown => {
|
const extractJson = (raw: string): unknown => {
|
||||||
const cleaned = raw
|
const cleaned = raw
|
||||||
.trim()
|
.trim()
|
||||||
@@ -237,83 +244,476 @@ const extractJson = (raw: string): unknown => {
|
|||||||
return JSON.parse(cleaned.slice(start, end + 1))
|
return JSON.parse(cleaned.slice(start, end + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parseGroupDailyReport = (
|
const createSignature = (...parts: Array<string | null | undefined>): string =>
|
||||||
raw: string,
|
parts
|
||||||
topSpeakers: ReportSpeakerRank[],
|
.map((part) => (part || '').replace(/\s+/g, ' ').trim())
|
||||||
activeTimeline: string
|
.filter(Boolean)
|
||||||
): GroupDailyReport => {
|
.join('|')
|
||||||
const root = asObject(extractJson(raw))
|
.toLowerCase()
|
||||||
const topics: ReportTopic[] = asArray(root.topics)
|
|
||||||
|
const parseTopics = (root: Record<string, unknown>): ReportTopic[] =>
|
||||||
|
asArray(root.topics)
|
||||||
.map((value) => {
|
.map((value) => {
|
||||||
const item = asObject(value)
|
const item = asObject(value)
|
||||||
return {
|
return {
|
||||||
title: asString(item.title),
|
title: asString(item.title),
|
||||||
timeRange: asString(item.timeRange),
|
timeRange: asString(item.timeRange),
|
||||||
heat: normalizeHeat(item.heat),
|
heat: normalizeHeat(item.heat),
|
||||||
participants: asArray(item.participants).map(asName).filter(Boolean).slice(0, 5),
|
participants: asArray(item.participants).map(normalizeName).filter(Boolean).slice(0, 5),
|
||||||
summary: asString(item.summary),
|
summary: asString(item.summary),
|
||||||
conclusion: asString(item.conclusion),
|
conclusion: asString(item.conclusion),
|
||||||
keywords: asStrings(item.keywords, 8)
|
conclusions: asArray(item.conclusions)
|
||||||
|
.map((entry) => {
|
||||||
|
const conclusion = asObject(entry)
|
||||||
|
return {
|
||||||
|
text: asString(conclusion.text),
|
||||||
|
sourceMessageIds: asStrings(conclusion.sourceMessageIds, 6)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.text)
|
||||||
|
.slice(0, 4),
|
||||||
|
keywords: asStrings(item.keywords, 8),
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 12)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((topic) => topic.title && topic.summary)
|
.filter((topic) => topic.title && topic.summary)
|
||||||
.slice(0, 7)
|
|
||||||
if (!topics.length) throw new Error('AI 日报中没有有效话题')
|
|
||||||
|
|
||||||
const resources: ReportResource[] = asArray(root.resources)
|
const parseResources = (root: Record<string, unknown>): ReportResource[] =>
|
||||||
|
asArray(root.resources)
|
||||||
.map((value) => {
|
.map((value) => {
|
||||||
const item = asObject(value)
|
const item = asObject(value)
|
||||||
return {
|
return {
|
||||||
title: asString(item.title),
|
title: asString(item.title),
|
||||||
description: asString(item.description),
|
description: asString(item.description),
|
||||||
sender: item.sender ? asName(item.sender) : undefined
|
sender: item.sender ? normalizeName(item.sender) : undefined,
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 6)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((item) => item.title && item.description)
|
.filter((item) => item.title && item.description)
|
||||||
const importantMessages: ReportImportantMessage[] = asArray(root.importantMessages)
|
|
||||||
|
const parseImportantMessages = (root: Record<string, unknown>): ReportImportantMessage[] =>
|
||||||
|
asArray(root.importantMessages)
|
||||||
.map((value) => {
|
.map((value) => {
|
||||||
const item = asObject(value)
|
const item = asObject(value)
|
||||||
return {
|
return {
|
||||||
sender: asName(item.sender),
|
sender: normalizeName(item.sender),
|
||||||
time: asString(item.time),
|
time: asString(item.time),
|
||||||
content: asString(item.content),
|
content: asString(item.content),
|
||||||
note: asString(item.note)
|
note: asString(item.note),
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 6),
|
||||||
|
importance: asNumber(item.importance),
|
||||||
|
confidence: asNumber(item.confidence)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((item) => item.sender && item.content)
|
.filter((item) => item.sender && item.content)
|
||||||
const quotes: ReportQuote[] = asArray(root.quotes)
|
|
||||||
|
const parseQuotes = (root: Record<string, unknown>): ReportQuote[] =>
|
||||||
|
asArray(root.quotes)
|
||||||
.map((value) => {
|
.map((value) => {
|
||||||
const item = asObject(value)
|
const item = asObject(value)
|
||||||
return {
|
return {
|
||||||
messages: asArray(item.messages)
|
messages: asArray(item.messages)
|
||||||
.map((messageValue) => {
|
.map((messageValue) => {
|
||||||
const message = asObject(messageValue)
|
const message = asObject(messageValue)
|
||||||
return { sender: asName(message.sender), content: asString(message.content) }
|
return {
|
||||||
|
sender: normalizeName(message.sender),
|
||||||
|
content: asString(message.content),
|
||||||
|
sourceMessageId: asString(message.sourceMessageId)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.filter((message) => message.sender && message.content),
|
.filter((message) => message.sender && message.content),
|
||||||
note: asString(item.note)
|
note: asString(item.note),
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
|
||||||
|
importance: asNumber(item.importance),
|
||||||
|
confidence: asNumber(item.confidence)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((quote) => quote.messages.length)
|
.filter((quote) => quote.messages.length)
|
||||||
.slice(0, 3)
|
|
||||||
const qa: ReportQuestionAnswer[] = asArray(root.qa)
|
const parseQa = (root: Record<string, unknown>): ReportQuestionAnswer[] =>
|
||||||
|
asArray(root.qa)
|
||||||
.map((value) => {
|
.map((value) => {
|
||||||
const item = asObject(value)
|
const item = asObject(value)
|
||||||
return {
|
return {
|
||||||
question: asString(item.question),
|
question: asString(item.question),
|
||||||
answer: asString(item.answer),
|
answer: asString(item.answer),
|
||||||
answerer: item.answerer ? asName(item.answerer) : undefined
|
answerer: item.answerer ? normalizeName(item.answerer) : undefined,
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((item) => item.question && item.answer)
|
.filter((item) => item.question && item.answer)
|
||||||
|
|
||||||
|
const parseTodos = (root: Record<string, unknown>): ReportTodoItem[] =>
|
||||||
|
asArray(root.todos)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
return {
|
||||||
|
task: asString(item.task),
|
||||||
|
owner: item.owner === null ? null : item.owner ? normalizeName(item.owner) : undefined,
|
||||||
|
deadline: item.deadline === null ? null : asNullableString(item.deadline),
|
||||||
|
topic: item.topic === null ? null : asNullableString(item.topic),
|
||||||
|
note: asString(item.note),
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
|
||||||
|
importance: asNumber(item.importance),
|
||||||
|
confidence: asNumber(item.confidence)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item.task)
|
||||||
|
|
||||||
|
const parseUnresolved = (root: Record<string, unknown>): ReportUnresolvedItem[] =>
|
||||||
|
asArray(root.unresolved)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
const status = asString(item.status)
|
||||||
|
const normalizedStatus: ReportUnresolvedItem['status'] =
|
||||||
|
status === '暂未回答' || status === '进行中' || status === '待跟进' ? status : '待跟进'
|
||||||
|
return {
|
||||||
|
question: asString(item.question),
|
||||||
|
owner: item.owner ? normalizeName(item.owner) : undefined,
|
||||||
|
status: normalizedStatus,
|
||||||
|
note: asString(item.note),
|
||||||
|
lastDiscussedAt: item.lastDiscussedAt === null ? null : asNullableString(item.lastDiscussedAt),
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
|
||||||
|
importance: asNumber(item.importance),
|
||||||
|
confidence: asNumber(item.confidence)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item.question && item.note)
|
||||||
|
|
||||||
|
const parseStorylines = (root: Record<string, unknown>): ReportStoryline[] =>
|
||||||
|
asArray(root.storylines)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
return {
|
||||||
|
title: asString(item.title),
|
||||||
|
stages: asArray(item.stages)
|
||||||
|
.map((stageValue) => {
|
||||||
|
const stage = asObject(stageValue)
|
||||||
|
return {
|
||||||
|
time: asString(stage.time),
|
||||||
|
event: asString(stage.event),
|
||||||
|
sourceMessageIds: asStrings(stage.sourceMessageIds, 6)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((stage) => stage.event),
|
||||||
|
result: asString(item.result),
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item.title && item.stages.length)
|
||||||
|
|
||||||
|
const parseReversals = (root: Record<string, unknown>): ReportReversal[] =>
|
||||||
|
asArray(root.reversals)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
return {
|
||||||
|
topic: asString(item.topic),
|
||||||
|
initialView: asString(item.initialView),
|
||||||
|
finalView: asString(item.finalView),
|
||||||
|
note: asString(item.note),
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item.topic && item.initialView && item.finalView)
|
||||||
|
|
||||||
|
const parseParticipantChains = (root: Record<string, unknown>): ReportParticipantChain[] =>
|
||||||
|
asArray(root.participantChains)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
return {
|
||||||
|
topic: asString(item.topic),
|
||||||
|
chain: asStrings(item.chain, 5),
|
||||||
|
note: asString(item.note),
|
||||||
|
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item.topic && item.chain.length)
|
||||||
|
|
||||||
|
const scoreByHeat = (heat: ReportHeat): number => (heat === '高' ? 0.95 : heat === '中' ? 0.75 : 0.55)
|
||||||
|
|
||||||
|
const scoreByCount = (count: number, max = 5): number => Math.min(1, Math.max(0.3, count / max))
|
||||||
|
|
||||||
|
const dedupeItems = <T>(
|
||||||
|
items: T[],
|
||||||
|
getSources: (item: T) => string[],
|
||||||
|
getSignature: (item: T) => string
|
||||||
|
): T[] => {
|
||||||
|
const seenSources = new Set<string>()
|
||||||
|
const seenSignatures = new Set<string>()
|
||||||
|
const result: T[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
const signature = getSignature(item)
|
||||||
|
const sources = getSources(item).filter(Boolean)
|
||||||
|
const sourceOverlap = sources.some((source) => seenSources.has(source))
|
||||||
|
if (signature && seenSignatures.has(signature)) continue
|
||||||
|
if (sourceOverlap && sources.length) continue
|
||||||
|
result.push(item)
|
||||||
|
if (signature) seenSignatures.add(signature)
|
||||||
|
for (const source of sources) seenSources.add(source)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByScore = <T>(items: T[], scorer: (item: T) => number): T[] =>
|
||||||
|
[...items].sort((left, right) => scorer(right) - scorer(left))
|
||||||
|
|
||||||
|
const buildSectionMeta = (
|
||||||
|
enabled: boolean,
|
||||||
|
displayedCount: number,
|
||||||
|
totalCount: number,
|
||||||
|
importance: number,
|
||||||
|
confidence: number
|
||||||
|
): ReportSectionMeta => ({
|
||||||
|
enabled: enabled && displayedCount > 0,
|
||||||
|
importance,
|
||||||
|
confidence,
|
||||||
|
totalCount,
|
||||||
|
displayedCount,
|
||||||
|
hiddenCount: Math.max(0, totalCount - displayedCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
const clampTopics = (topics: ReportTopic[], config: ReportModeConfig): ReportTopic[] =>
|
||||||
|
topics.slice(0, config.maxTopics).map((topic) => ({
|
||||||
|
...topic,
|
||||||
|
summary: truncate(topic.summary, config.topicSummaryLength),
|
||||||
|
conclusions: (topic.conclusions || [])
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((entry) => ({ ...entry, text: truncate(entry.text, config.noteLength) })),
|
||||||
|
conclusion: topic.conclusion ? truncate(topic.conclusion, config.noteLength) : topic.conclusion,
|
||||||
|
keywords: topic.keywords.slice(0, 3)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const attachHighImpactImage = (
|
||||||
|
topics: ReportTopic[],
|
||||||
|
gallery: GroupDailyReport['media']['gallery']
|
||||||
|
): ReportTopic[] => {
|
||||||
|
if (!gallery.length) return topics
|
||||||
|
const [firstImage, ...rest] = gallery
|
||||||
|
const nextTopics = topics.map((topic, index) =>
|
||||||
|
index === 0 && firstImage.replyCount && firstImage.replyCount >= 3
|
||||||
|
? {
|
||||||
|
...topic,
|
||||||
|
image: {
|
||||||
|
imageUrl: firstImage.imageUrl,
|
||||||
|
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
|
||||||
|
sourceMessageIds: firstImage.sourceMessageIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: topic
|
||||||
|
)
|
||||||
|
gallery.splice(0, rest.length >= 0 ? 1 : 0)
|
||||||
|
return nextTopics
|
||||||
|
}
|
||||||
|
|
||||||
|
const postProcessReport = (
|
||||||
|
report: GroupDailyReport,
|
||||||
|
mode: ReportMode,
|
||||||
|
metadata: GroupReportMetadata
|
||||||
|
): GroupDailyReport => {
|
||||||
|
const config = REPORT_MODE_CONFIG[mode]
|
||||||
|
|
||||||
|
const topicsScored = sortByScore(report.topics, (item) => scoreByHeat(item.heat))
|
||||||
|
const topicsDeduped = dedupeItems(
|
||||||
|
topicsScored,
|
||||||
|
(item) => item.sourceMessageIds || [],
|
||||||
|
(item) => createSignature(item.title, item.summary)
|
||||||
|
)
|
||||||
|
let gallery = [...report.media.gallery]
|
||||||
|
const topics = attachHighImpactImage(clampTopics(topicsDeduped, config), gallery)
|
||||||
|
|
||||||
|
const importantMessagesRaw = sortByScore(report.importantMessages, (item) =>
|
||||||
|
Math.max(item.importance || 0, item.confidence || 0.6)
|
||||||
|
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.55 : true))
|
||||||
|
const importantMessages = dedupeItems(
|
||||||
|
importantMessagesRaw,
|
||||||
|
(item) => item.sourceMessageIds || [],
|
||||||
|
(item) => createSignature(item.content, item.note)
|
||||||
|
)
|
||||||
|
.slice(0, config.maxImportantMessages)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
content: truncate(item.content, config.noteLength),
|
||||||
|
note: truncate(item.note, config.noteLength)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const todosRaw = sortByScore(report.todos, (item) =>
|
||||||
|
Math.max(item.importance || 0.6, item.confidence || 0.5)
|
||||||
|
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.5 : true))
|
||||||
|
const todos = dedupeItems(
|
||||||
|
todosRaw,
|
||||||
|
(item) => item.sourceMessageIds || [],
|
||||||
|
(item) => createSignature(item.task, item.owner || '', item.deadline || '')
|
||||||
|
)
|
||||||
|
.slice(0, config.maxTodos)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
task: truncate(item.task, config.noteLength),
|
||||||
|
note: item.note ? truncate(item.note, config.noteLength) : item.note
|
||||||
|
}))
|
||||||
|
|
||||||
|
const unresolvedRaw = sortByScore(report.unresolved, (item) =>
|
||||||
|
Math.max(item.importance || 0.6, item.confidence || 0.5)
|
||||||
|
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.45 : true))
|
||||||
|
const unresolved = dedupeItems(
|
||||||
|
unresolvedRaw,
|
||||||
|
(item) => item.sourceMessageIds || [],
|
||||||
|
(item) => createSignature(item.question, item.note)
|
||||||
|
)
|
||||||
|
.slice(0, config.maxUnresolved)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
question: truncate(item.question, config.noteLength),
|
||||||
|
note: truncate(item.note, config.noteLength)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const quotesRaw = sortByScore(report.quotes, (item) =>
|
||||||
|
Math.max(item.importance || 0.55, item.confidence || 0.55, scoreByCount(item.messages.length, 4))
|
||||||
|
)
|
||||||
|
const quotes = dedupeItems(
|
||||||
|
quotesRaw,
|
||||||
|
(item) => item.sourceMessageIds || item.messages.map((message) => message.sourceMessageId || ''),
|
||||||
|
(item) => createSignature(item.note, item.messages.map((message) => message.content).join('|'))
|
||||||
|
)
|
||||||
|
.slice(0, config.maxQuotes)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
messages: item.messages.slice(0, config.maxQuoteMessages).map((message) => ({
|
||||||
|
...message,
|
||||||
|
content: truncate(message.content, Math.min(config.noteLength, 38))
|
||||||
|
})),
|
||||||
|
note: truncate(item.note, config.noteLength)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const resources = report.resources.slice(0, config.maxResources)
|
||||||
|
const qa = report.qa.slice(0, config.maxQa)
|
||||||
|
const storylines = report.storylines.slice(0, config.maxStorylines)
|
||||||
|
const reversals = report.reversals.slice(0, config.maxReversals)
|
||||||
|
const participantChains = report.participantChains.slice(0, config.maxChains)
|
||||||
|
const voiceHighlights = report.media.voiceHighlights.slice(0, config.maxVoiceHighlights)
|
||||||
|
const funBadges = report.media.funBadges.slice(0, config.maxBadges)
|
||||||
|
const keywords = report.keywords.slice(0, config.maxKeywords)
|
||||||
|
|
||||||
|
const conclusionCount =
|
||||||
|
topics.reduce((count, topic) => count + (topic.conclusions?.length || 0), 0) +
|
||||||
|
importantMessages.length
|
||||||
|
const summaryStats = {
|
||||||
|
messageCount: metadata.messageCount,
|
||||||
|
activeUsers: metadata.activeUsers,
|
||||||
|
topicCount: topics.length,
|
||||||
|
mediaCount: metadata.mediaMessageCount || 0,
|
||||||
|
imageCount: metadata.imageCount || 0,
|
||||||
|
voiceCount: metadata.voiceCount || 0,
|
||||||
|
stickerCount: metadata.stickerCount || 0,
|
||||||
|
conclusionCount,
|
||||||
|
todoCount: todos.length,
|
||||||
|
unresolvedCount: unresolved.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const hero = {
|
||||||
|
headline:
|
||||||
|
report.hero?.headline || topics[0]?.title || `${metadata.groupName}${mode === 'compact' ? '速览' : '日报'}`,
|
||||||
|
summary: truncate(
|
||||||
|
report.hero?.summary || report.overview || '今天群里有新的讨论进展。',
|
||||||
|
mode === 'compact' ? 84 : 120
|
||||||
|
),
|
||||||
|
keyTakeaway: report.hero?.keyTakeaway ? truncate(report.hero.keyTakeaway, config.noteLength) : undefined,
|
||||||
|
pendingNote: report.hero?.pendingNote ? truncate(report.hero.pendingNote, config.noteLength) : undefined,
|
||||||
|
statusLine:
|
||||||
|
report.hero?.statusLine ||
|
||||||
|
`今日形成 ${summaryStats.conclusionCount} 个结论 · ${summaryStats.todoCount} 个待办 · ${summaryStats.unresolvedCount} 个问题尚未解决`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionMeta: Partial<Record<ReportSectionKey, ReportSectionMeta>> = {
|
||||||
|
hero: buildSectionMeta(true, 1, 1, 1, 0.95),
|
||||||
|
topics: buildSectionMeta(config.enabledSections.includes('topics'), topics.length, report.topics.length, 0.98, 0.85),
|
||||||
|
importantMessages: buildSectionMeta(
|
||||||
|
config.enabledSections.includes('importantMessages'),
|
||||||
|
importantMessages.length,
|
||||||
|
report.importantMessages.length,
|
||||||
|
0.95,
|
||||||
|
0.82
|
||||||
|
),
|
||||||
|
actions: buildSectionMeta(
|
||||||
|
config.enabledSections.includes('actions'),
|
||||||
|
todos.length + unresolved.length,
|
||||||
|
report.todos.length + report.unresolved.length,
|
||||||
|
0.97,
|
||||||
|
0.8
|
||||||
|
),
|
||||||
|
moments: buildSectionMeta(config.enabledSections.includes('moments'), quotes.length, report.quotes.length, 0.75, 0.72),
|
||||||
|
analytics: buildSectionMeta(config.enabledSections.includes('analytics'), 1, 1, 0.8, 0.95),
|
||||||
|
keywords: buildSectionMeta(config.enabledSections.includes('keywords'), keywords.length, report.keywords.length, 0.68, 0.9),
|
||||||
|
resources: buildSectionMeta(config.enabledSections.includes('resources'), resources.length, report.resources.length, 0.55, 0.75),
|
||||||
|
qa: buildSectionMeta(config.enabledSections.includes('qa'), qa.length, report.qa.length, 0.62, 0.78),
|
||||||
|
storylines: buildSectionMeta(config.enabledSections.includes('storylines'), storylines.length, report.storylines.length, 0.63, 0.74),
|
||||||
|
reversals: buildSectionMeta(config.enabledSections.includes('reversals'), reversals.length, report.reversals.length, 0.54, 0.72),
|
||||||
|
gallery: buildSectionMeta(config.enabledSections.includes('gallery'), gallery.length, report.media.gallery.length, 0.6, 0.8),
|
||||||
|
voices: buildSectionMeta(config.enabledSections.includes('voices'), voiceHighlights.length, report.media.voiceHighlights.length, 0.56, 0.84),
|
||||||
|
badges: buildSectionMeta(config.enabledSections.includes('badges'), funBadges.length, report.media.funBadges.length, 0.45, 0.65),
|
||||||
|
chains: buildSectionMeta(config.enabledSections.includes('chains'), participantChains.length, report.participantChains.length, 0.58, 0.71)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
|
...report,
|
||||||
|
mode,
|
||||||
|
hero,
|
||||||
topics,
|
topics,
|
||||||
resources,
|
resources,
|
||||||
importantMessages,
|
importantMessages,
|
||||||
quotes,
|
quotes,
|
||||||
qa,
|
qa,
|
||||||
|
todos,
|
||||||
|
unresolved,
|
||||||
|
storylines,
|
||||||
|
reversals,
|
||||||
|
participantChains,
|
||||||
|
keywords,
|
||||||
|
media: {
|
||||||
|
gallery,
|
||||||
|
voiceHighlights,
|
||||||
|
funBadges
|
||||||
|
},
|
||||||
|
summaryStats,
|
||||||
|
sectionMeta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseGroupDailyReport = (
|
||||||
|
raw: string,
|
||||||
|
topSpeakers: ReportSpeakerRank[],
|
||||||
|
activeTimeline: string,
|
||||||
|
voiceLeaderboard: ReportVoiceLeaderboardItem[],
|
||||||
|
metadata: GroupReportMetadata,
|
||||||
|
media: GroupDailyReport['media']
|
||||||
|
): GroupDailyReport => {
|
||||||
|
const root = asObject(extractJson(raw))
|
||||||
|
const topics = parseTopics(root)
|
||||||
|
if (!topics.length) throw new Error('AI 日报中没有有效话题')
|
||||||
|
|
||||||
|
const heroRoot = asObject(root.hero)
|
||||||
|
const report: GroupDailyReport = {
|
||||||
|
overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
|
||||||
|
mode: metadata.reportMode || 'compact',
|
||||||
|
hero:
|
||||||
|
heroRoot && Object.keys(heroRoot).length
|
||||||
|
? {
|
||||||
|
headline: asString(heroRoot.headline) || topics[0]?.title || '今日群聊速览',
|
||||||
|
summary: asString(heroRoot.summary) || asString(root.overview) || '今天群里有新的讨论进展。',
|
||||||
|
keyTakeaway: asString(heroRoot.keyTakeaway),
|
||||||
|
pendingNote: asString(heroRoot.pendingNote),
|
||||||
|
statusLine: asString(heroRoot.statusLine)
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
topics,
|
||||||
|
resources: parseResources(root),
|
||||||
|
importantMessages: parseImportantMessages(root),
|
||||||
|
quotes: parseQuotes(root),
|
||||||
|
qa: parseQa(root),
|
||||||
|
todos: parseTodos(root),
|
||||||
|
unresolved: parseUnresolved(root),
|
||||||
|
storylines: parseStorylines(root),
|
||||||
|
reversals: parseReversals(root),
|
||||||
|
participantChains: parseParticipantChains(root),
|
||||||
analytics: {
|
analytics: {
|
||||||
topicHeat: topics.map((topic) => ({
|
topicHeat: topics.map((topic) => ({
|
||||||
topic: topic.title,
|
topic: topic.title,
|
||||||
@@ -323,8 +723,12 @@ export const parseGroupDailyReport = (
|
|||||||
topSpeakers: topSpeakers.map((speaker) => ({
|
topSpeakers: topSpeakers.map((speaker) => ({
|
||||||
name: speaker.name,
|
name: speaker.name,
|
||||||
count: Math.max(0, asNumber(speaker.count))
|
count: Math.max(0, asNumber(speaker.count))
|
||||||
}))
|
})),
|
||||||
|
voiceLeaderboard
|
||||||
},
|
},
|
||||||
keywords: asStrings(root.keywords, 15)
|
keywords: asStrings(root.keywords, 15),
|
||||||
|
media
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return postProcessReport(report, metadata.reportMode || 'compact', metadata)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,35 @@
|
|||||||
export type ReportHeat = '高' | '中' | '低'
|
export type ReportHeat = '高' | '中' | '低'
|
||||||
|
export type ReportMode = 'compact' | 'full'
|
||||||
|
export type ReportSectionKey =
|
||||||
|
| 'hero'
|
||||||
|
| 'topics'
|
||||||
|
| 'importantMessages'
|
||||||
|
| 'actions'
|
||||||
|
| 'moments'
|
||||||
|
| 'analytics'
|
||||||
|
| 'keywords'
|
||||||
|
| 'resources'
|
||||||
|
| 'qa'
|
||||||
|
| 'storylines'
|
||||||
|
| 'reversals'
|
||||||
|
| 'gallery'
|
||||||
|
| 'voices'
|
||||||
|
| 'badges'
|
||||||
|
| 'chains'
|
||||||
|
|
||||||
|
export interface ReportSectionMeta {
|
||||||
|
enabled: boolean
|
||||||
|
importance: number
|
||||||
|
confidence: number
|
||||||
|
totalCount: number
|
||||||
|
displayedCount: number
|
||||||
|
hiddenCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTopicConclusion {
|
||||||
|
text: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportTopic {
|
export interface ReportTopic {
|
||||||
title: string
|
title: string
|
||||||
@@ -7,13 +38,21 @@ export interface ReportTopic {
|
|||||||
participants: string[]
|
participants: string[]
|
||||||
summary: string
|
summary: string
|
||||||
conclusion?: string
|
conclusion?: string
|
||||||
|
conclusions?: ReportTopicConclusion[]
|
||||||
keywords: string[]
|
keywords: string[]
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
image?: {
|
||||||
|
imageUrl?: string
|
||||||
|
note: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportResource {
|
export interface ReportResource {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
sender?: string
|
sender?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportImportantMessage {
|
export interface ReportImportantMessage {
|
||||||
@@ -21,22 +60,30 @@ export interface ReportImportantMessage {
|
|||||||
time: string
|
time: string
|
||||||
content: string
|
content: string
|
||||||
note: string
|
note: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
importance?: number
|
||||||
|
confidence?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportQuoteMessage {
|
export interface ReportQuoteMessage {
|
||||||
sender: string
|
sender: string
|
||||||
content: string
|
content: string
|
||||||
|
sourceMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportQuote {
|
export interface ReportQuote {
|
||||||
messages: ReportQuoteMessage[]
|
messages: ReportQuoteMessage[]
|
||||||
note: string
|
note: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
importance?: number
|
||||||
|
confidence?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportQuestionAnswer {
|
export interface ReportQuestionAnswer {
|
||||||
question: string
|
question: string
|
||||||
answer: string
|
answer: string
|
||||||
answerer?: string
|
answerer?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportTopicHeat {
|
export interface ReportTopicHeat {
|
||||||
@@ -49,19 +96,135 @@ export interface ReportSpeakerRank {
|
|||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReportHero {
|
||||||
|
headline: string
|
||||||
|
summary: string
|
||||||
|
keyTakeaway?: string
|
||||||
|
pendingNote?: string
|
||||||
|
statusLine?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportMediaGalleryItem {
|
||||||
|
sender: string
|
||||||
|
time: string
|
||||||
|
imageUrl: string
|
||||||
|
note: string
|
||||||
|
stats?: string
|
||||||
|
inferenceLabel?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
replyCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportVoiceHighlight {
|
||||||
|
title: string
|
||||||
|
sender: string
|
||||||
|
note: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportVoiceLeaderboardItem {
|
||||||
|
sender: string
|
||||||
|
count: number
|
||||||
|
durationSec: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportFunBadge {
|
||||||
|
title: string
|
||||||
|
owner: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTodoItem {
|
||||||
|
task: string
|
||||||
|
owner?: string | null
|
||||||
|
deadline?: string | null
|
||||||
|
topic?: string | null
|
||||||
|
note?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
importance?: number
|
||||||
|
confidence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportUnresolvedItem {
|
||||||
|
question: string
|
||||||
|
owner?: string
|
||||||
|
status: '待跟进' | '暂未回答' | '进行中'
|
||||||
|
note: string
|
||||||
|
lastDiscussedAt?: string | null
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
importance?: number
|
||||||
|
confidence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportStorylineStage {
|
||||||
|
time: string
|
||||||
|
event: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportStoryline {
|
||||||
|
title: string
|
||||||
|
stages: ReportStorylineStage[]
|
||||||
|
result?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportReversal {
|
||||||
|
topic: string
|
||||||
|
initialView: string
|
||||||
|
finalView: string
|
||||||
|
note?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportParticipantChain {
|
||||||
|
topic: string
|
||||||
|
chain: string[]
|
||||||
|
note?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSummaryStats {
|
||||||
|
messageCount: number
|
||||||
|
activeUsers: number
|
||||||
|
topicCount: number
|
||||||
|
mediaCount: number
|
||||||
|
imageCount: number
|
||||||
|
voiceCount: number
|
||||||
|
stickerCount: number
|
||||||
|
conclusionCount: number
|
||||||
|
todoCount: number
|
||||||
|
unresolvedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupDailyReport {
|
export interface GroupDailyReport {
|
||||||
overview: string
|
overview: string
|
||||||
|
mode?: ReportMode
|
||||||
|
hero?: ReportHero
|
||||||
topics: ReportTopic[]
|
topics: ReportTopic[]
|
||||||
resources: ReportResource[]
|
resources: ReportResource[]
|
||||||
importantMessages: ReportImportantMessage[]
|
importantMessages: ReportImportantMessage[]
|
||||||
quotes: ReportQuote[]
|
quotes: ReportQuote[]
|
||||||
qa: ReportQuestionAnswer[]
|
qa: ReportQuestionAnswer[]
|
||||||
|
todos: ReportTodoItem[]
|
||||||
|
unresolved: ReportUnresolvedItem[]
|
||||||
|
storylines: ReportStoryline[]
|
||||||
|
reversals: ReportReversal[]
|
||||||
|
participantChains: ReportParticipantChain[]
|
||||||
analytics: {
|
analytics: {
|
||||||
topicHeat: ReportTopicHeat[]
|
topicHeat: ReportTopicHeat[]
|
||||||
activeTimeline: string
|
activeTimeline: string
|
||||||
topSpeakers: ReportSpeakerRank[]
|
topSpeakers: ReportSpeakerRank[]
|
||||||
|
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||||
}
|
}
|
||||||
keywords: string[]
|
keywords: string[]
|
||||||
|
media: {
|
||||||
|
gallery: ReportMediaGalleryItem[]
|
||||||
|
voiceHighlights: ReportVoiceHighlight[]
|
||||||
|
funBadges: ReportFunBadge[]
|
||||||
|
}
|
||||||
|
summaryStats?: ReportSummaryStats
|
||||||
|
sectionMeta?: Partial<Record<ReportSectionKey, ReportSectionMeta>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupReportMetadata {
|
export interface GroupReportMetadata {
|
||||||
@@ -70,12 +233,20 @@ export interface GroupReportMetadata {
|
|||||||
dateRange: string
|
dateRange: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
activeUsers: number
|
activeUsers: number
|
||||||
|
imageCount?: number
|
||||||
|
voiceCount?: number
|
||||||
|
stickerCount?: number
|
||||||
|
mediaMessageCount?: number
|
||||||
timeSpan: string
|
timeSpan: string
|
||||||
generatedAt: string
|
generatedAt: string
|
||||||
recordNote: string
|
recordNote: string
|
||||||
footerNote: string
|
footerNote: string
|
||||||
heroParticipants: string[]
|
heroParticipants: string[]
|
||||||
avatars: Record<string, string | undefined>
|
avatars: Record<string, string | undefined>
|
||||||
|
reportMode?: ReportMode
|
||||||
|
talker?: string
|
||||||
|
timeRange?: string
|
||||||
|
warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupReportExportRequest {
|
export interface GroupReportExportRequest {
|
||||||
@@ -88,5 +259,6 @@ export interface GroupReportExportResult {
|
|||||||
htmlPath?: string
|
htmlPath?: string
|
||||||
pngPath?: string
|
pngPath?: string
|
||||||
imageDataUrl?: string
|
imageDataUrl?: string
|
||||||
|
warnings?: string[]
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user