diff --git a/.env.example b/.env.example
index 285378e..5436001 100644
--- a/.env.example
+++ b/.env.example
@@ -8,7 +8,7 @@ VITE_AUTO_LOGIN=false
# AI API Configuration (Optional, can be entered in UI)
VITE_DEEPSEEK_API_KEY=
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.
VITE_FILTER_MSG_TYPES=
diff --git a/README.md b/README.md
index f727874..83a07f1 100644
--- a/README.md
+++ b/README.md
@@ -18,10 +18,13 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
+ - **一句话速览**: 首屏突出今日核心结论与待跟进事项。
- **实用信息与资源**: 提取分享的链接、资源等信息。
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
- **有趣对话或金句**: 收录群内的精彩对话。
- **问题与解答**: 整理群内的问答内容。
+ - **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
+ - **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云/关键词**: 可视化展示群聊关键词。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
diff --git a/docs/skill/wechatexplorer-reader/SKILL.md b/docs/skill/wechatexplorer-reader/SKILL.md
index 09c925f..78632e3 100644
--- a/docs/skill/wechatexplorer-reader/SKILL.md
+++ b/docs/skill/wechatexplorer-reader/SKILL.md
@@ -141,12 +141,31 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
"qa": [
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
],
+ "unresolved": [
+ { "question": "待跟进问题", "owner": "相关人(可选)", "status": "待跟进", "note": "为什么还没结束" }
+ ],
+ "storylines": [
+ { "title": "剧情线", "stages": [{ "time": "10:12", "event": "提出问题" }], "result": "可选结果" }
+ ],
+ "reversals": [
+ { "topic": "某话题", "initialView": "最初判断", "finalView": "最终判断", "note": "可选说明" }
+ ],
+ "participantChains": [
+ { "topic": "某话题", "chain": ["A 提出", "B 补充", "C 收尾"], "note": "可选说明" }
+ ],
"analytics": {
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
"activeTimeline": "10:00-12:00 为最活跃时段",
- "topSpeakers": [{ "name": "张三", "count": 58 }]
+ "topSpeakers": [{ "name": "张三", "count": 58 }],
+ "voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
},
- "keywords": ["高频词1", "高频词2"]
+ "keywords": ["高频词1", "高频词2"],
+ "hero": {
+ "headline": "一句抓重点的日报标题",
+ "summary": "一句概览",
+ "keyTakeaway": "最重要结论",
+ "pendingNote": "待跟进事项"
+ }
},
"metadata": {
"groupName": "技术交流",
diff --git a/resources/mobile_daily_report.html b/resources/mobile_daily_report.html
index 784ff69..40d35e4 100644
--- a/resources/mobile_daily_report.html
+++ b/resources/mobile_daily_report.html
@@ -27,13 +27,13 @@
.report {
width: 430px;
margin: 0 auto;
- padding: 22px 14px 34px;
+ padding: 20px 14px 34px;
}
.hero,
- .card,
- .section {
+ .section,
+ .card {
background: #fff;
- border-radius: 16px;
+ border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
@@ -41,32 +41,35 @@
}
.hero-top {
display: flex;
- align-items: center;
+ align-items: flex-start;
justify-content: space-between;
gap: 14px;
- min-width: 0;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.hero h1 {
+ margin: 0;
font-size: 23px;
line-height: 1.2;
- margin: 0 0 8px;
font-weight: 900;
}
.sub {
+ margin-top: 8px;
color: #667085;
font-size: 13px;
- line-height: 1.5;
+ line-height: 1.55;
}
- .record-note {
- color: #485465;
- font-weight: 650;
- }
- .overview {
- margin-top: 2px;
+ .mode-tag {
+ display: inline-flex;
+ margin-top: 10px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: #eef8f2;
+ color: #07a352;
+ font-size: 11px;
+ font-weight: 800;
}
.avatar-grid {
width: 58px;
@@ -83,6 +86,49 @@
border-radius: 50%;
object-fit: cover;
}
+ .hero-headline {
+ margin-top: 14px;
+ padding: 14px;
+ border-radius: 16px;
+ background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
+ }
+ .hero-headline b {
+ display: block;
+ font-size: 17px;
+ color: #076c39;
+ }
+ .hero-headline p {
+ margin: 8px 0 0;
+ font-size: 13px;
+ line-height: 1.65;
+ color: #1f2933;
+ }
+ .hero-inline-notes {
+ display: grid;
+ gap: 8px;
+ margin-top: 10px;
+ }
+ .hero-note,
+ .hero-status {
+ padding: 10px 12px;
+ border-radius: 12px;
+ font-size: 12px;
+ line-height: 1.5;
+ }
+ .hero-status {
+ margin-top: 10px;
+ background: #f7faf9;
+ color: #076c39;
+ font-weight: 700;
+ }
+ .hero-note.takeaway {
+ background: #eef8f2;
+ color: #076c39;
+ }
+ .hero-note.pending {
+ background: #fff8e8;
+ color: #8a5a00;
+ }
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -94,14 +140,11 @@
border-radius: 12px;
padding: 10px 6px;
text-align: center;
- min-width: 0;
- overflow: hidden;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
- white-space: nowrap;
line-height: 1.25;
}
.stat span {
@@ -109,16 +152,16 @@
color: #667085;
}
.section {
- margin-top: 14px;
+ margin-top: 18px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
- font-size: 18px;
+ margin-bottom: 14px;
+ font-size: 19px;
font-weight: 900;
- margin-bottom: 12px;
}
.section-title::before {
content: '';
@@ -127,11 +170,23 @@
border-radius: 99px;
background: #07c160;
}
+ .section-subtitle {
+ margin: 10px 0 6px;
+ color: #667085;
+ font-size: 12px;
+ font-weight: 700;
+ }
+ .section-more {
+ margin-top: 10px;
+ color: #98a2b3;
+ font-size: 11px;
+ text-align: right;
+ }
.card {
padding: 14px;
margin-top: 10px;
- box-shadow: none;
border: 1px solid #edf0f2;
+ box-shadow: none;
}
.topic-title-row {
display: flex;
@@ -140,17 +195,17 @@
gap: 8px;
}
.topic-title-row h3 {
+ margin: 0;
font-size: 16px;
line-height: 1.35;
- margin: 0;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
- border-radius: 999px;
padding: 4px 8px;
+ border-radius: 999px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
@@ -165,19 +220,44 @@
background: #eef5ff;
color: #1677ff;
}
- .red {
- background: #fff1f0;
- color: #ff4d4f;
- }
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
+ margin: 10px 0 0;
font-size: 13px;
line-height: 1.65;
- margin: 10px 0 0;
+ }
+ .topic-conclusions {
+ display: grid;
+ gap: 8px;
+ margin-top: 10px;
+ }
+ .topic-conclusion {
+ padding: 8px 10px;
+ border-radius: 10px;
+ background: #edf9f1;
+ color: #076c39;
+ font-size: 12px;
+ line-height: 1.5;
+ font-weight: 700;
+ }
+ .topic-inline-image {
+ display: grid;
+ grid-template-columns: 76px 1fr;
+ gap: 10px;
+ margin-top: 10px;
+ padding: 10px;
+ border-radius: 12px;
+ background: #f7faf9;
+ }
+ .topic-inline-image img {
+ width: 76px;
+ height: 76px;
+ border-radius: 10px;
+ object-fit: cover;
}
.participants {
display: flex;
@@ -189,7 +269,6 @@
display: inline-flex;
align-items: center;
gap: 5px;
- min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
@@ -198,7 +277,6 @@
width: 24px;
height: 24px;
border-radius: 50%;
- object-fit: cover;
}
.person-chip b {
max-width: 58px;
@@ -220,17 +298,6 @@
background: #f2f4f7;
color: #667085;
}
- .resource {
- padding: 11px 12px;
- background: #f7f8fa;
- border-radius: 12px;
- margin-top: 8px;
- font-size: 13px;
- line-height: 1.55;
- }
- .resource b {
- color: #1677ff;
- }
.important-card {
display: flex;
gap: 10px;
@@ -255,9 +322,9 @@
color: #1f2933;
}
.important-text {
+ margin-top: 5px;
font-size: 13px;
line-height: 1.55;
- margin-top: 5px;
}
.important-note {
margin-top: 8px;
@@ -269,6 +336,34 @@
font-size: 12px;
line-height: 1.45;
}
+ .action-grid {
+ display: grid;
+ gap: 10px;
+ }
+ .action-card {
+ border-radius: 14px;
+ padding: 12px;
+ }
+ .todo-card {
+ background: #eef5ff;
+ }
+ .unresolved-card {
+ background: #fff8e8;
+ }
+ .action-card b {
+ display: block;
+ color: #1f2933;
+ font-size: 14px;
+ }
+ .action-card div {
+ margin-top: 6px;
+ font-size: 12px;
+ line-height: 1.55;
+ color: #485465;
+ }
+ .action-note {
+ color: #667085;
+ }
.chat-block {
background: #f0f2f5;
border-radius: 14px;
@@ -299,49 +394,126 @@
line-height: 1.5;
}
.quote-note {
- background: #fff8e1;
- border-radius: 10px;
- padding: 9px 10px;
margin-top: 10px;
+ padding: 9px 10px;
+ border-radius: 10px;
+ background: #fff8e1;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
- .qa-card {
- background: #f8fafc;
- border-radius: 14px;
- padding: 12px;
+ .qa-card,
+ .resource {
margin-top: 10px;
+ padding: 12px;
+ border-radius: 14px;
+ background: #f8fafc;
}
- .qa-card b {
+ .qa-card b,
+ .resource b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
- .qa-card div {
+ .qa-card div,
+ .resource {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
- .bar-row {
+ .storyline-card,
+ .chain-card {
+ background: #f8fafc;
+ }
+ .storyline-steps {
display: grid;
- grid-template-columns: 82px 1fr;
gap: 8px;
- align-items: center;
- margin-top: 9px;
+ margin-top: 10px;
+ }
+ .storyline-step {
+ display: grid;
+ grid-template-columns: 50px 1fr;
+ gap: 10px;
+ }
+ .storyline-step span {
+ color: #8a94a6;
font-size: 12px;
}
- .bar {
- height: 10px;
- background: #edf1f5;
- border-radius: 999px;
- overflow: hidden;
+ .storyline-step b {
+ font-size: 13px;
+ line-height: 1.5;
}
- .bar i {
- display: block;
- height: 100%;
- background: #07c160;
+ .chain-flow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ margin-top: 10px;
+ }
+ .chain-flow span {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 9px;
border-radius: 999px;
+ background: #eef8f2;
+ color: #076c39;
+ font-size: 12px;
+ font-weight: 700;
+ }
+ .chain-flow i {
+ font-style: normal;
+ color: #98a2b3;
+ }
+ .gallery-card {
+ display: grid;
+ grid-template-columns: 112px 1fr;
+ gap: 12px;
+ margin-top: 10px;
+ padding: 12px;
+ background: #f7faf9;
+ border-radius: 14px;
+ }
+ .gallery-image {
+ width: 112px;
+ height: 112px;
+ border-radius: 12px;
+ object-fit: cover;
+ background: #e5e7eb;
+ }
+ .gallery-stats {
+ display: inline-flex;
+ margin-top: 7px;
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: #eef5ff;
+ color: #1677ff;
+ font-size: 11px;
+ font-weight: 700;
+ }
+ .badge-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ }
+ .badge-card {
+ background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
+ border: 1px solid #edf0f2;
+ border-radius: 14px;
+ padding: 12px;
+ }
+ .badge-card b {
+ display: block;
+ margin-top: 8px;
+ font-size: 15px;
+ }
+ .badge-card p {
+ margin: 8px 0 0;
+ font-size: 12px;
+ line-height: 1.55;
+ }
+ .data-grid {
+ display: grid;
+ gap: 12px;
}
.rank {
display: flex;
@@ -350,6 +522,9 @@
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
+ .rank:last-child {
+ border-bottom: none;
+ }
.rank img {
width: 30px;
height: 30px;
@@ -402,7 +577,58 @@
color: #8a94a6;
}
.empty-section {
- display: none;
+ display: none !important;
+ }
+ .compact .report {
+ padding-top: 18px;
+ }
+ .compact .section {
+ margin-top: 12px;
+ padding: 14px;
+ }
+ .compact .card {
+ padding: 11px;
+ }
+ .compact .important-card,
+ .compact .gallery-card,
+ .compact .chat-block {
+ padding: 10px;
+ }
+ .compact .section-title {
+ margin-bottom: 9px;
+ font-size: 17px;
+ }
+ .compact .hero-headline p,
+ .compact .card p,
+ .compact .important-text,
+ .compact .chat-bubble {
+ line-height: 1.5;
+ }
+ .compact .participants,
+ .compact .keywords,
+ .compact .hero-inline-notes {
+ gap: 6px;
+ }
+ .compact .topic-conclusions {
+ gap: 6px;
+ }
+ .compact .topic-inline-image {
+ grid-template-columns: 64px 1fr;
+ padding: 8px;
+ }
+ .compact .topic-inline-image img {
+ width: 64px;
+ height: 64px;
+ }
+ .compact .stats {
+ gap: 6px;
+ margin-top: 14px;
+ }
+ .compact .stat {
+ padding: 8px 6px;
+ }
+ .compact .stat b {
+ font-size: 17px;
}
@media (max-width: 430px) {
html,
@@ -414,83 +640,139 @@
padding-left: 12px;
padding-right: 12px;
}
- .stats {
- gap: 6px;
- }
- .stat b {
- font-size: 16px;
- }
}
-
+
{{GROUP_NAME}}日报
-
-
{{DATE_RANGE}}
-
{{RECORD_NOTE}}
-
{{OVERVIEW}}
-
+
{{DATE_RANGE}}
{{RECORD_NOTE}}
+
{{REPORT_MODE_LABEL}}
{{HERO_AVATARS}}
+
+
{{HERO_HEADLINE}}
+
{{HERO_SUMMARY}}
+
+ {{HERO_STATUS_LINE}}
+
+
{{HERO_TAKEAWAY}}
+
{{HERO_PENDING}}
+
{{MESSAGE_COUNT}}消息数
{{ACTIVE_USERS}}活跃人数
-
{{TIME_SPAN}}持续时长
-
{{TOPIC_COUNT}}主要话题
+
{{TOPIC_COUNT}}话题数
+
{{MEDIA_COUNT}}媒体消息
-
+
今日讨论热点
{{TOPIC_CARDS}}
+ {{TOPICS_MORE_NOTE}}
-
+
+ 重要消息
+ {{IMPORTANT_MESSAGES}}
+ {{MESSAGES_MORE_NOTE}}
+
+
+
+ 待办事项和未解决问题
+ 待办事项
+ {{TODO_CARDS}}
+ 尚未解决
+ {{UNRESOLVED_CARDS}}
+ {{ACTIONS_MORE_NOTE}}
+
+
+
+ 今日名场面
+ {{QUOTE_BLOCKS}}
+ {{QUOTES_MORE_NOTE}}
+
+
+
+ 今日群数据
+
+
+
话唠榜 TOP5
+ {{RANK_ITEMS}}
+
+
+
最活跃时段:{{ACTIVITY_TIMELINE}}
+
今日状态:形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。
+
+
+
+
+
+ 关键词
+ {{CLOUD_TAGS}}
+ {{KEYWORDS_MORE_NOTE}}
+
+
+
实用信息与资源
{{RESOURCE_ITEMS}}
+ {{RESOURCES_MORE_NOTE}}
-
- 重要消息汇总
- {{IMPORTANT_MESSAGES}}
-
-
-
- 有趣对话或金句
- {{QUOTE_BLOCKS}}
-
-
-
+
问题与解答
{{QA_CARDS}}
+ {{QA_MORE_NOTE}}
-
- 群内数据可视化
- {{HEAT_BARS}}
-
-
- 话唠榜 TOP5(基于已读取记录估算)
-
- {{RANK_ITEMS}}
-
-
-
活跃时间线:{{ACTIVITY_TIMELINE}}
-
+
+ 今日剧情时间线
+ {{STORYLINE_CARDS}}
+ {{STORYLINES_MORE_NOTE}}
-
- 词云/关键词
- {{CLOUD_TAGS}}
+
+ 群聊反转现场
+ {{REVERSAL_CARDS}}
+ {{REVERSALS_MORE_NOTE}}
+
+
+
+ 今日群相册
+ {{GALLERY_CARDS}}
+ {{GALLERY_MORE_NOTE}}
+
+
+
+ 语音之最
+ {{VOICE_CARDS}}
+ {{VOICE_MORE_NOTE}}
+
+
+
+ 语音时长榜
+ {{VOICE_RANK_CARDS}}
+
+
+
+ 今日临时人设
+ {{BADGE_CARDS}}
+ {{BADGES_MORE_NOTE}}
+
+
+
+ 话题参与链路
+ {{CHAIN_CARDS}}
+ {{CHAINS_MORE_NOTE}}
diff --git a/scripts/render-report-fixtures.mjs b/scripts/render-report-fixtures.mjs
new file mode 100644
index 0000000..c7b9333
--- /dev/null
+++ b/scripts/render-report-fixtures.mjs
@@ -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, ''')
+
+const replacePlaceholder = (html, key, value) => html.replaceAll(`{{${key}}}`, value)
+
+const avatarSvg = (label, color) =>
+ `data:image/svg+xml;base64,${Buffer.from(
+ ``
+ ).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) => `
`)
+ .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) => `${escapeHtml(entry.text)}
`)
+ .join('')
+ const image = topic.image?.imageUrl
+ ? `
${escapeHtml(topic.image.note)}
`
+ : ''
+ return `${escapeHtml(topic.title)}
${escapeHtml(topic.heat)}热${escapeHtml(topic.timeRange)}
${escapeHtml(topic.summary)}
${conclusions ? `
${conclusions}
` : ''}${image}
${topic.participants.map((name) => `
${escapeHtml(name)}`).join('')}
${topic.keywords.map((word) => `${escapeHtml(word)}`).join('')}
`
+ })
+ .join('')
+ const importantMessages = report.importantMessages
+ .map((message) => `})
${escapeHtml(message.sender)}${escapeHtml(message.time)}
${escapeHtml(message.content)}
${escapeHtml(message.note)}
`)
+ .join('')
+ const todoCards = (report.todos || []).map((item) => `${escapeHtml(item.task)}${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}
${item.note ? `
${escapeHtml(item.note)}
` : ''}
`).join('')
+ const unresolvedCards = (report.unresolved || []).map((item) => `${escapeHtml(item.question)}${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}
${escapeHtml(item.note)}
`).join('')
+ const quoteBlocks = report.quotes.map((quote) => `${quote.messages.map((message) => `
})
${escapeHtml(message.sender)}
${escapeHtml(message.content)}
`).join('')}
${escapeHtml(quote.note)}
`).join('')
+ const rankItems = report.analytics.topSpeakers.map((speaker, index) => `
${index + 1}. ${escapeHtml(speaker.name)}${speaker.count} 条 `).join('')
+ const cloudTags = report.keywords.map((word, index) => `${escapeHtml(word)}`).join('')
+ const resourceItems = (report.resources || []).map((resource) => `${escapeHtml(resource.title)}${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}
${escapeHtml(resource.description)}
`).join('')
+ const qaCards = (report.qa || []).map((item) => `Q:${escapeHtml(item.question)}A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
`).join('')
+ const storylineCards = (report.storylines || []).map((item) => `${escapeHtml(item.title)}
${item.stages.map((stage) => `
${escapeHtml(stage.time)}${escapeHtml(stage.event)}
`).join('')}
${item.result ? `
${escapeHtml(item.result)}
` : ''}
`).join('')
+ const reversalCards = (report.reversals || []).map((item) => `${escapeHtml(item.topic)}最初:${escapeHtml(item.initialView)}
后来:${escapeHtml(item.finalView)}
${item.note ? `
${escapeHtml(item.note)}
` : ''}
`).join('')
+ const galleryCards = (report.media.gallery || []).map((item) => `
${escapeHtml(item.sender)}${escapeHtml(item.time)}
${item.stats ? `
${escapeHtml(item.stats)}
` : ''}
${escapeHtml(item.note)}
${item.inferenceLabel ? `
${escapeHtml(item.inferenceLabel)}
` : ''}
`).join('')
+ const voiceCards = (report.media.voiceHighlights || []).map((item) => `${escapeHtml(item.title)} · ${escapeHtml(item.sender)}${escapeHtml(item.note)}
`).join('')
+ const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `
${index + 1}. ${escapeHtml(item.sender)}${item.count} 条 · ${item.durationSec} 秒 `).join('')
+ const badgeCards = (report.media.funBadges || []).map((item) => `${escapeHtml(item.title)}${escapeHtml(item.owner)}${escapeHtml(item.note)}
`).join('')
+ const chainCards = (report.participantChains || []).map((item) => `${escapeHtml(item.topic)}
${item.chain.map((node) => `${escapeHtml(node)}`).join('→')}
${item.note ? `
${escapeHtml(item.note)}
` : ''}
`).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 ? `另有 ${report.sectionMeta.topics.hiddenCount} 条内容,请在完整版中查看
` : '',
+ MESSAGES_EMPTY_CLASS: report.sectionMeta.importantMessages?.enabled ? '' : 'empty-section',
+ IMPORTANT_MESSAGES: importantMessages,
+ MESSAGES_MORE_NOTE: report.sectionMeta.importantMessages?.hiddenCount ? `另有 ${report.sectionMeta.importantMessages.hiddenCount} 条内容,请在完整版中查看
` : '',
+ 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 ? `另有 ${report.sectionMeta.actions.hiddenCount} 条内容,请在完整版中查看
` : '',
+ QUOTES_EMPTY_CLASS: report.sectionMeta.moments?.enabled ? '' : 'empty-section',
+ QUOTE_BLOCKS: quoteBlocks,
+ QUOTES_MORE_NOTE: report.sectionMeta.moments?.hiddenCount ? `另有 ${report.sectionMeta.moments.hiddenCount} 条内容,请在完整版中查看
` : '',
+ 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 ? `另有 ${report.sectionMeta.keywords.hiddenCount} 个关键词,请在完整版中查看
` : '',
+ 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()
+})
diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts
index f431b9b..203ce96 100644
--- a/src/main/group-report-service.ts
+++ b/src/main/group-report-service.ts
@@ -138,6 +138,29 @@ const heatClass = (heat: ReportHeat): string => {
const replacePlaceholder = (html: string, key: string, value: string): string =>
html.replaceAll(`{{${key}}}`, value)
+const modeLabel = (mode: GroupReportMetadata['reportMode']): string =>
+ mode === 'full' ? '完整版' : '精简版'
+
+const sectionMeta = (
+ request: GroupReportExportRequest,
+ key: keyof NonNullable
+) => request.report.sectionMeta?.[key]
+
+const sectionClass = (
+ request: GroupReportExportRequest,
+ key: keyof NonNullable,
+ hasContent: boolean
+): string => (sectionMeta(request, key)?.enabled && hasContent ? '' : 'empty-section')
+
+const overflowNote = (
+ request: GroupReportExportRequest,
+ key: keyof NonNullable
+): string => {
+ const meta = sectionMeta(request, key)
+ if (request.metadata.reportMode !== 'compact' || !meta?.hiddenCount) return ''
+ return `另有 ${meta.hiddenCount} 条内容,请在完整版中查看
`
+}
+
const renderReportHtml = async (request: GroupReportExportRequest): Promise => {
const { report, metadata } = request
const avatarNames = new Set(metadata.heroParticipants)
@@ -147,6 +170,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise avatarNames.add(message.sender))
)
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
+ report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
+ report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
+ report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
const avatars = new Map()
await Promise.all(
@@ -168,7 +194,21 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise${escapeHtml(topic.title)}
${escapeHtml(topic.heat)}热
${escapeHtml(topic.timeRange)}
${escapeHtml(topic.summary)}
- ${topic.conclusion ? `${escapeHtml(topic.conclusion)}
` : ''}
+ ${
+ topic.conclusions?.length
+ ? `${topic.conclusions
+ .slice(0, 2)
+ .map((entry) => `
${escapeHtml(entry.text)}
`)
+ .join('')}
`
+ : topic.conclusion
+ ? `${escapeHtml(topic.conclusion)}
`
+ : ''
+ }
+ ${
+ topic.image?.imageUrl
+ ? `
${escapeHtml(topic.image.note)}
`
+ : ''
+ }
${topic.participants
.slice(0, 5)
.map(
@@ -213,18 +253,104 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise
- `Q:${escapeHtml(item.question)}A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
`
+ (item) => `
+
${escapeHtml(item.task)}
+
${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}
+ ${item.note ? `
${escapeHtml(item.note)}
` : ''}
+
`
)
.join('')
- const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
- const heatBars = report.analytics.topicHeat
+ const unresolvedCards = (report.unresolved || [])
.map(
- (item) =>
- `${escapeHtml(item.topic)}
`
+ (item) => `
+
${escapeHtml(item.question)}
+
${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}
+
${escapeHtml(item.note)}
+
`
+ )
+ .join('')
+
+ const storylineCards = (report.storylines || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.title)}
+
${item.stages
+ .map(
+ (stage) => `
+ ${escapeHtml(stage.time || '--:--')}
+ ${escapeHtml(stage.event)}
+
`
+ )
+ .join('')}
+ ${item.result ? `
${escapeHtml(item.result)}
` : ''}
+
`
+ )
+ .join('')
+
+ const reversalCards = (report.reversals || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.topic)}
+
最初:${escapeHtml(item.initialView)}
+
后来:${escapeHtml(item.finalView)}
+ ${item.note ? `
${escapeHtml(item.note)}
` : ''}
+
`
+ )
+ .join('')
+
+ const chainCards = (report.participantChains || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.topic)}
+
${item.chain.map((node) => `${escapeHtml(node)}`).join('→')}
+ ${item.note ? `
${escapeHtml(item.note)}
` : ''}
+
`
+ )
+ .join('')
+
+ const galleryCards = (report.media?.gallery || [])
+ .map(
+ (item) => `
+

+
+
${escapeHtml(item.sender)}${escapeHtml(item.time)}
+ ${item.stats ? `
${escapeHtml(item.stats)}
` : ''}
+
${escapeHtml(item.note)}
+ ${item.inferenceLabel ? `
${escapeHtml(item.inferenceLabel)}
` : ''}
+
+
`
+ )
+ .join('')
+
+ const voiceCards = (report.media?.voiceHighlights || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.title)} · ${escapeHtml(item.sender)}
+
${escapeHtml(item.note)}
+
`
+ )
+ .join('')
+
+ const voiceRankCards = (report.analytics.voiceLeaderboard || [])
+ .map(
+ (item, index) => `
+
})
+
${index + 1}. ${escapeHtml(item.sender)}
+
${item.count} 条 · ${item.durationSec} 秒
+
`
+ )
+ .join('')
+
+ const badgeCards = (report.media?.funBadges || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.title)}
+
${escapeHtml(item.owner)}
+
${escapeHtml(item.note)}
+
`
)
.join('')
@@ -244,31 +370,98 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise
+ `Q:${escapeHtml(item.question)}A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
`
+ )
+ .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')
const values: Record = {
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
+ REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange),
- RECORD_NOTE: escapeHtml(`基于 WechatExplorer 加载的 ${metadata.messageCount} 条记录`),
- OVERVIEW: escapeHtml(report.overview),
+ RECORD_NOTE: escapeHtml(metadata.recordNote),
+ 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,
- MESSAGE_COUNT: String(metadata.messageCount),
- ACTIVE_USERS: String(metadata.activeUsers),
+ MESSAGE_COUNT: String(summaryStats.messageCount),
+ ACTIVE_USERS: String(summaryStats.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
- TOPIC_COUNT: String(report.topics.length),
+ TOPIC_COUNT: String(summaryStats.topicCount),
+ MEDIA_COUNT: String(summaryStats.mediaCount),
TOPIC_CARDS: topicCards,
- RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
+ TOPICS_EMPTY_CLASS: sectionClass(request, 'topics', report.topics.length > 0),
+ TOPICS_MORE_NOTE: overflowNote(request, 'topics'),
+ RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
RESOURCE_ITEMS: resourceItems,
- MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
+ RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
+ MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
IMPORTANT_MESSAGES: importantMessages,
- QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
+ MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
+ QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
QUOTE_BLOCKS: quoteBlocks,
- QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
+ QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
+ ACTIONS_EMPTY_CLASS: sectionClass(request, 'actions', report.todos.length + report.unresolved.length > 0),
+ TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
+ TODO_CARDS: todoCards,
+ UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
+ UNRESOLVED_CARDS: unresolvedCards,
+ ACTIONS_MORE_NOTE: overflowNote(request, 'actions'),
+ QA_EMPTY_CLASS: sectionClass(request, 'qa', report.qa.length > 0),
QA_CARDS: qaCards,
- HEAT_BARS: heatBars,
+ QA_MORE_NOTE: overflowNote(request, 'qa'),
+ STORYLINES_EMPTY_CLASS: sectionClass(request, 'storylines', report.storylines?.length > 0),
+ STORYLINE_CARDS: storylineCards,
+ STORYLINES_MORE_NOTE: overflowNote(request, 'storylines'),
+ REVERSALS_EMPTY_CLASS: sectionClass(request, 'reversals', report.reversals?.length > 0),
+ REVERSAL_CARDS: reversalCards,
+ REVERSALS_MORE_NOTE: overflowNote(request, 'reversals'),
+ CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0),
+ CHAIN_CARDS: chainCards,
+ CHAINS_MORE_NOTE: overflowNote(request, 'chains'),
+ GALLERY_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
+ GALLERY_CARDS: galleryCards,
+ GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
+ VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
+ VOICE_CARDS: voiceCards,
+ VOICE_MORE_NOTE: overflowNote(request, 'voices'),
+ VOICE_RANK_EMPTY_CLASS: sectionClass(request, 'voices', report.analytics.voiceLeaderboard?.length > 0),
+ VOICE_RANK_CARDS: voiceRankCards,
+ BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
+ BADGE_CARDS: badgeCards,
+ BADGES_MORE_NOTE: overflowNote(request, 'badges'),
RANK_ITEMS: rankItems,
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
+ CONCLUSION_COUNT: String(summaryStats.conclusionCount),
+ TODO_COUNT: String(summaryStats.todoCount),
+ UNRESOLVED_COUNT: String(summaryStats.unresolvedCount),
CLOUD_TAGS: cloudTags,
+ KEYWORDS_EMPTY_CLASS: sectionClass(request, 'keywords', report.keywords.length > 0),
+ KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'),
+ ANALYTICS_EMPTY_CLASS: sectionClass(request, 'analytics', true),
GENERATED_AT: escapeHtml(metadata.generatedAt),
FOOTER_NOTE: escapeHtml(metadata.footerNote)
}
@@ -327,7 +520,7 @@ export const exportGroupReport = async (
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
- const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
+ const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${request.metadata.reportMode === 'full' ? '完整版' : '精简版'}`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const htmlStartedAt = new Date()
diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx
index 024728f..ac2cf3d 100644
--- a/src/renderer/src/components/ChatWindow.tsx
+++ b/src/renderer/src/components/ChatWindow.tsx
@@ -1,111 +1,124 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import { Message, Contact } from '../../../shared/types'
-import { ChatHeader } from './chat/ChatHeader'
-import { ChatStatusBar } from './chat/ChatStatusBar'
-import { DataTrustBar } from './chat/DataTrustBar'
-import { EmptyConversationState } from './chat/EmptyConversationState'
-import { ExportRange } from './chat/ExportMenu'
-import { MessageList } from './chat/MessageList'
+import { VoicePlayer } from './VoicePlayer'
+import { RichMessageBubble } from './RichMessageBubble'
+import { ImageBubble } from './ImageBubble'
+import {
+ buildGroupReportInput,
+ GROUP_REPORT_SYSTEM_PROMPT,
+ parseGroupDailyReport
+} from '../utils/group-report'
+import type { ReportMode } from '../../../shared/group-report'
interface ChatWindowProps {
contact: Contact | null
messages: Message[]
- isLoadingMessages?: boolean
contentFilter?: string
- dateRange?: string
- onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void
onRefreshData?: () => void
- onCreateGroupReport?: () => void
- isAiLoading?: boolean
}
-const MAX_RENDERED_MESSAGES = 600
-const DATE_RANGE_LABELS: Record = {
- today: '今天',
- yesterday: '昨日',
- '7': '7 天',
- '30': '30 天',
- all: '全部'
-}
+type SummaryDateRange = 'today' | 'yesterday' | '7days'
+type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system'
-const formatClock = (date: Date): string =>
- `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
+const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
+ { value: 'today', label: '今天' },
+ { value: 'yesterday', label: '昨日' },
+ { value: '7days', label: '最近 7 天' }
+]
-const formatRangeDate = (date: Date, now: Date): string => {
- const clock = formatClock(date)
- if (date.getFullYear() === now.getFullYear()) {
- return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
- }
- return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
-}
+const SUMMARY_TYPE_OPTIONS: {
+ value: SummaryMessageType
+ label: string
+ messageTypes: string[]
+}[] = [
+ { value: 'text', label: '文本', messageTypes: ['普通文本'] },
+ { value: 'image', label: '图片', messageTypes: ['图片'] },
+ { value: 'sticker', label: '表情包', messageTypes: ['表情包'] },
+ { value: 'video', label: '视频', messageTypes: ['视频'] },
+ { value: 'voice', label: '语音', messageTypes: ['语音'] },
+ { value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] },
+ { value: 'system', label: '系统消息', messageTypes: ['系统消息'] }
+]
-const getChatHeaderRangeLabel = (range: string): string => {
+const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => {
const now = new Date()
- const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
- const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
-
- if (range === 'today') return `今天 00:00—现在`
- if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
- if (range === '7') {
- const start = new Date(Date.now() - 7 * 86400000)
- return `${formatRangeDate(start, now)}—现在`
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
+ const endTime = Math.floor(Date.now() / 1000)
+ if (range === 'yesterday') {
+ return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
}
- if (range === '30') {
- const start = new Date(Date.now() - 30 * 86400000)
- return `${formatRangeDate(start, now)}—现在`
+ if (range === '7days') {
+ return { startTime: startOfToday - 6 * 86400, endTime }
}
- if (range === 'all') return '全部记录'
- return DATE_RANGE_LABELS[range] || '当前范围'
+ return { startTime: startOfToday, endTime }
}
const ChatWindow: React.FC = ({
contact,
messages,
- isLoadingMessages,
contentFilter,
- dateRange = 'today',
- onContentFilterChange,
onRefresh,
- onRefreshData,
- onCreateGroupReport,
- isAiLoading = false
+ onRefreshData
}) => {
const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
)
- const messageListRef = useRef(null)
const messagesEndRef = useRef(null)
+ const [generatedImage, setGeneratedImage] = useState(null)
+ const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
const [previewImage, setPreviewImage] = useState(null)
const [imageScale, setImageScale] = useState(0.75)
const [imageRotation, setImageRotation] = useState(0)
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
- const imageViewerStageRef = useRef(null)
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null
)
const [showAvatar, setShowAvatar] = useState(true)
- const [isAtLatest, setIsAtLatest] = useState(true)
- const scrollToBottom = useCallback((): void => {
+ // AI Settings
+ const [showSettingsModal, setShowSettingsModal] = useState(false)
+ const [apiKey, setApiKey] = useState(() => localStorage.getItem('ai_api_key') || '')
+ const [baseURL, setBaseURL] = useState(
+ () => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
+ )
+ const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-v4-flash')
+ const [summaryDateRange, setSummaryDateRange] = useState('today')
+ const [summaryMessageTypes, setSummaryMessageTypes] = useState(['text'])
+ const [reportMode, setReportMode] = useState(
+ () => (localStorage.getItem('group_report_mode') as ReportMode) || 'compact'
+ )
+
+ const handleSaveSettings = (): void => {
+ if (!summaryMessageTypes.length) {
+ alert('请至少选择一种消息类型')
+ return
+ }
+ localStorage.setItem('ai_api_key', apiKey)
+ localStorage.setItem('ai_base_url', baseURL)
+ localStorage.setItem('ai_model', model)
+ localStorage.setItem('group_report_mode', reportMode)
+ setShowSettingsModal(false)
+ AIChat()
+ }
+
+ const toggleSummaryMessageType = (type: SummaryMessageType): void => {
+ setSummaryMessageTypes((current) =>
+ current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
+ )
+ }
+
+ const scrollToBottom = (): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
- setIsAtLatest(true)
- }, [])
-
- const handleMessageListScroll = useCallback((event: React.UIEvent): void => {
- const target = event.currentTarget
- const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
- setIsAtLatest(distanceToBottom <= 24)
- }, [])
+ }
useEffect(() => {
- const frame = window.requestAnimationFrame(() => scrollToBottom())
- return () => window.cancelAnimationFrame(frame)
- }, [messages, scrollToBottom])
+ scrollToBottom()
+ }, [messages])
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
- setImageScale(1)
+ setImageScale(0.75)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
@@ -116,18 +129,17 @@ const ChatWindow: React.FC = ({
}
const zoomImage = (delta: number): void => {
- setImageScale((prev) => Math.min(8, Math.max(0.1, Number((prev + delta).toFixed(2)))))
+ setImageScale((prev) => Math.min(3, Math.max(0.25, Number((prev + delta).toFixed(2)))))
}
const resetImageTransform = (): void => {
- setImageScale(1)
+ setImageScale(0.75)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
const handleViewerWheel = (event: React.WheelEvent): void => {
event.preventDefault()
- event.stopPropagation()
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
}
@@ -154,25 +166,7 @@ const ChatWindow: React.FC = ({
imageDragRef.current = null
}
- useEffect(() => {
- if (!previewImage) return
-
- const previousOverflow = document.body.style.overflow
- document.body.style.overflow = 'hidden'
-
- const stage = imageViewerStageRef.current
- const preventBackgroundWheel = (event: WheelEvent): void => {
- event.preventDefault()
- }
- stage?.addEventListener('wheel', preventBackgroundWheel, { passive: false })
-
- return () => {
- document.body.style.overflow = previousOverflow
- stage?.removeEventListener('wheel', preventBackgroundWheel)
- }
- }, [previewImage])
-
- const handleExport = (days: ExportRange): void => {
+ const handleExport = (days: number | 'all'): void => {
if (!messages.length) return
let filtered = messages
@@ -231,6 +225,67 @@ const ChatWindow: React.FC = ({
document.body.removeChild(link)
}
+ const [isLoading, setIsLoading] = useState(false)
+
+ const AIChat = async (): Promise => {
+ if (!contact) return
+ if (!summaryMessageTypes.length) {
+ alert('请至少选择一种消息类型')
+ return
+ }
+ setIsLoading(true)
+ try {
+ const { startTime, endTime } = getSummaryDateRange(summaryDateRange)
+ const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime)
+ const allowedTypes = new Set(
+ SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap(
+ (option) => option.messageTypes
+ )
+ )
+ const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
+ if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
+
+ const input = await buildGroupReportInput(reportMessages, contact, isGroupChat, reportMode)
+ console.log('🚀 ~ AIChat ~ input:', input)
+ console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
+ const result = await window.api.aiChat(
+ [
+ { role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
+ { role: 'user', content: input.prompt }
+ ],
+ { apiKey, model, baseURL }
+ )
+
+ if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
+ 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 })
+ if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
+ throw new Error(exported.error || '日报文件生成失败')
+ }
+ setGeneratedImage(exported.imageDataUrl)
+ setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
+ } catch (error) {
+ console.error('AI Call Failed:', error)
+ alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ const handleCopyImage = async (): Promise => {
+ if (!generatedImage) return
+ const result = await window.api.copyImage(generatedImage)
+ if (result.success) {
+ alert('复制成功')
+ }
+ }
+
const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
@@ -242,53 +297,194 @@ const ChatWindow: React.FC = ({
return typeMatch && contentMatch
})
}, [messages, contentFilter])
- const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
- const renderedMessages = React.useMemo(
- () => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
- [filteredMessages]
- )
- if (!contact) return
-
- const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
+ if (!contact) {
+ return (
+
+ )
+ }
return (
-
0}
- onContentFilterChange={onContentFilterChange || (() => undefined)}
- onRefresh={onRefresh}
- onRefreshData={onRefreshData}
- onExport={handleExport}
- onOpenAiSettings={onCreateGroupReport || (() => undefined)}
- />
-
-
-
+
+
{contact.m_nsNickName}
+
+
+
+
+ {filteredMessages.map((msg) => {
+ const isMine = msg.from === 'assistant'
+ const isSystem = msg.from === 'system' || msg.type === '系统消息'
+ const displayName = isMine
+ ? '我'
+ : isGroupChat
+ ? msg.name || msg.from
+ : contact.m_nsNickName
+ const avatarSrc = isMine ? msg.img : msg.img || contact.avatar
+ const isVoice = msg.type === '语音'
+ const isImage = msg.type === '图片'
+ const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'].includes(
+ msg.type
+ )
+
+ if (isSystem) {
+ return (
+
+
{msg.content}
+
{msg.datetime}
+
+ )
+ }
+
+ return (
+
+ {!isMine && showAvatar && (
+
+ {avatarSrc ? (
+

+ ) : (
+ (displayName || '?').charAt(0)
+ )}
+
+ )}
+
+ {!isMine && isGroupChat &&
{displayName}
}
+
+ {isVoice && msg.sessionId ? (
+
+ ) : isImage && msg.contentData && msg.contentData.type === 'image' ? (
+
+ ) : isRichMedia && msg.contentData ? (
+
+ ) : (
+
{msg.content}
+ )}
+
+
+ {msg.datetime}
+ {msg.type}
+
+
+ {isMine && showAvatar && (
+
+ {avatarSrc ?

: '我'}
+
+ )}
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 加载模态框 */}
+ {isLoading && (
+
+
+
🤖
+
正在生成群聊日报...
+
+ 正在分析记录、处理头像并生成 HTML 和长图
+
+
+
+ )}
+
+ {/* 图片预览模态框 */}
+ {generatedImage && (
+ setGeneratedImage(null)}>
+
e.stopPropagation()}>
+
+
+

+
+
+
+
+ {reportPaths && (
+
+ )}
+
+
+
+
+ )}
{previewImage && (
@@ -319,7 +515,6 @@ const ChatWindow: React.FC = ({
= ({
)}
+
+ {/* AI Settings Modal */}
+ {showSettingsModal && (
+ setShowSettingsModal(false)}>
+
e.stopPropagation()}>
+
AI 设置
+
+
时间范围
+
+ {SUMMARY_DATE_OPTIONS.map((option) => (
+
+ ))}
+
+
+
+
+
消息类型
+
+ {SUMMARY_TYPE_OPTIONS.map((option) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ setBaseURL(e.target.value)}
+ placeholder="https://api.deepseek.com"
+ style={{ width: '95%', padding: '8px' }}
+ />
+
+
+
+ setApiKey(e.target.value)}
+ placeholder="Enter your API Key"
+ style={{ width: '95%', padding: '8px' }}
+ />
+
+
+
+
+
+
+
+ )}
)
}
diff --git a/src/renderer/src/utils/group-report-facts.ts b/src/renderer/src/utils/group-report-facts.ts
new file mode 100644
index 0000000..789aaca
--- /dev/null
+++ b/src/renderer/src/utils/group-report-facts.ts
@@ -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()
+ 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
+): 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 => 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 => 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()
+ 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 => {
+ 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()
+ const hourCounts = new Map()
+ const avatars: Record = {}
+ 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
+ }
+}
diff --git a/src/renderer/src/utils/group-report.ts b/src/renderer/src/utils/group-report.ts
index 5a28a29..e67a376 100644
--- a/src/renderer/src/utils/group-report.ts
+++ b/src/renderer/src/utils/group-report.ts
@@ -1,304 +1,206 @@
-import { Contact, Message } from '../../../shared/types'
import {
GroupDailyReport,
GroupReportMetadata,
ReportHeat,
ReportImportantMessage,
+ ReportMode,
+ ReportParticipantChain,
ReportQuestionAnswer,
ReportQuote,
ReportResource,
+ ReportReversal,
+ ReportSectionKey,
+ ReportSectionMeta,
ReportSpeakerRank,
- ReportTopic
+ ReportStoryline,
+ ReportTodoItem,
+ ReportTopic,
+ ReportUnresolvedItem,
+ ReportVoiceLeaderboardItem
} from '../../../shared/group-report'
-
-export type SummaryDateRange = 'today' | 'yesterday' | '7days'
-export type SummaryMessageType =
- | 'text'
- | 'image'
- | 'sticker'
- | 'video'
- | 'voice'
- | 'share'
- | 'system'
-
-export const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
- { value: 'today', label: '今天' },
- { value: 'yesterday', label: '昨日' },
- { value: '7days', label: '近 7 天' }
-]
-
-export const SUMMARY_TYPE_OPTIONS: {
- value: SummaryMessageType
- label: string
- messageTypes: string[]
- description: string
-}[] = [
- {
- value: 'text',
- label: '文本',
- messageTypes: ['普通文本'],
- description: '使用文本内容、发送者和时间。'
- },
- {
- value: 'image',
- label: '图片',
- messageTypes: ['图片'],
- description: '不做视觉识别,仅使用图片类型、发送者和时间。'
- },
- {
- value: 'sticker',
- label: '表情包',
- messageTypes: ['表情包'],
- description: '不理解表情内容,仅按类型参与统计。'
- },
- {
- value: 'video',
- label: '视频',
- messageTypes: ['视频'],
- description: '不理解视频画面,仅按类型参与统计。'
- },
- {
- value: 'voice',
- label: '语音',
- messageTypes: ['语音'],
- description: '当前不转写语音,仅参与数量和活跃度统计。'
- },
- {
- value: 'share',
- label: '分享/引用',
- messageTypes: ['分享消息', '名片', '位置', '通话'],
- description: '使用解析到的标题、引用文本或类型信息。'
- },
- {
- value: 'system',
- label: '系统消息',
- messageTypes: ['系统消息'],
- description: '使用系统消息文本或类型信息。'
- }
-]
+import { Contact, Message } from '../../../shared/types'
+import { buildGroupReportFacts } from './group-report-facts'
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
-原则:
+总原则:
1. 不得编造聊天中没有的事实、结论、参与者或链接内容。
2. 仅使用输入中的昵称,不输出 wxid、微信 ID、会话 ID 等内部标识。
-3. 图片、表情、语音、视频或链接内容不可见时,仅标注消息类型,不要猜测。
-4. 摘要说明发生了什么、大家如何回应、最后形成什么结论或氛围。
-5. 语气准确、轻巧、有信息密度,避免侮辱性和歧视性评价。
-6. 没有实际内容的可选栏目输出空数组,不要凑数。
-7. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
+3. 图片、表情、语音、视频或链接内容不可见时,只能描述消息类型、互动效果和上下文,不能猜图片具体内容。
+4. 先合并同一事件,再按重要性排序,避免一个事件在多个模块大段重复出现。
+5. 优先保留结论、待办、未解决问题、明确通知,再保留趣味内容。
+6. 负责人、截止日期、图片内容必须有聊天证据;没有证据就返回 null 或省略。
+7. 不为了填满模板而生成内容,没有就输出空数组。
+8. 所有候选条目尽量返回 sourceMessageIds,便于程序去重和追溯。
+9. 精简版面向 30 秒阅读,摘要必须短;完整版可以保留更多候选项。
+10. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
JSON 结构必须为:
{
"overview": "1至2句整体讨论风格与氛围",
+ "hero": {
+ "headline": "一句抓重点的日报标题",
+ "summary": "一句总结今天发生了什么,最多3行",
+ "keyTakeaway": "最重要结论,没有可空",
+ "pendingNote": "最值得跟进的一项,没有可空",
+ "statusLine": "例如:今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决"
+ },
"topics": [{
"title": "话题标题",
"timeRange": "HH:mm-HH:mm",
"heat": "高|中|低",
"participants": ["昵称"],
- "summary": "话题摘要",
- "conclusion": "结论或氛围",
- "keywords": ["关键词"]
+ "summary": "100字以内",
+ "conclusion": "可选简短结论",
+ "conclusions": [{"text":"关键结论","sourceMessageIds":["消息ID"]}],
+ "keywords": ["关键词"],
+ "sourceMessageIds": ["消息ID"]
}],
- "resources": [{"title":"资源名","description":"用途或内容","sender":"昵称"}],
- "importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要"}],
- "quotes": [{"messages":[{"sender":"昵称","content":"简短原话"}],"note":"点评"}],
- "qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称"}],
+ "resources": [{"title":"资源名","description":"用途或内容","sender":"昵称","sourceMessageIds":["消息ID"]}],
+ "importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要","sourceMessageIds":["消息ID"],"importance":0.95,"confidence":0.9}],
+ "quotes": [{"messages":[{"sender":"昵称","content":"简短原话","sourceMessageId":"消息ID"}],"note":"为什么好笑或值得看","sourceMessageIds":["消息ID"],"importance":0.8,"confidence":0.8}],
+ "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": ["关键词"]
}
-topics 提取 3 至 7 个,参与者最多 5 人,quotes 最多 3 组,keywords 输出 8 至 15 个。`
-
-const isInternalIdentifier = (value: string): boolean =>
- /@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
-
-export const getSummaryDateRange = (
- range: SummaryDateRange
-): { startTime: number; endTime: number } => {
- const now = new Date()
- const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
- const endTime = Math.floor(Date.now() / 1000)
- if (range === 'yesterday') {
- return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
- }
- if (range === '7days') {
- return { startTime: startOfToday - 6 * 86400, endTime }
- }
- return { startTime: startOfToday, endTime }
-}
-
-export const isInternalName = (value?: string): boolean => {
- const text = String(value || '').trim()
- return (
- !text || /^wxid_/i.test(text) || /@chatroom$/i.test(text) || /^[a-z0-9_-]{18,}$/i.test(text)
- )
-}
-
-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'
- })
+额外要求:
+- 精简版:热点最多4个、重要消息最多4条、待办最多5条、未解决最多3条、名场面最多2组、每组最多4条、关键词最多12个。
+- 完整版:可以保留更多候选项,但仍要去重和排序。
+- 如果某个字段不确定,请返回 null 或空数组,不要猜。`
export interface GroupReportInput {
prompt: string
metadata: GroupReportMetadata
topSpeakers: ReportSpeakerRank[]
activeTimeline: string
+ voiceLeaderboard: ReportVoiceLeaderboardItem[]
+ media: GroupDailyReport['media']
}
-export const buildGroupReportInput = (
+const REPORT_MODE_LABEL: Record = {
+ 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 = {
+ 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[],
contact: Contact | null,
- isGroup: boolean
-): GroupReportInput => {
- const rows = messages.map((message) => ({
- datetime: message.datetime,
- timestamp: new Date(message.datetime).getTime(),
- sender: summarySender(message, contact, isGroup),
- content: summaryContent(message),
- avatar: message.img
- }))
+ isGroup: boolean,
+ reportMode: ReportMode
+): Promise => {
+ const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode)
+ const transcript = facts.transcriptRows
+ .map((row) => `[${row.id}] ${row.datetime} ${row.sender}:${row.content}`)
+ .join('\n')
- 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()
- const hourCounts = new Map()
- const avatars: Record = {}
- 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 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: rows.length,
- activeUsers: speakerCounts.size,
- timeSpan,
- generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
- recordNote: `基于 WechatExplorer 加载的 ${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。
-会话名:${groupName}
-时间范围:${dateRange}
-消息数:${rows.length}
-活跃人数:${speakerCounts.size}
+会话名:${facts.metadata.groupName}
+时间范围:${facts.metadata.dateRange}
+消息数:${facts.metadata.messageCount}
+活跃人数:${facts.metadata.activeUsers}
+目标模式:${REPORT_MODE_LABEL[reportMode]}
完整性:仅基于当前应用已加载的记录。
+媒体与结构化事实(可引用行为,不可猜测图片内容):
+${facts.factsPrompt || '无'}
+
聊天记录:
${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 =>
@@ -307,9 +209,9 @@ const asObject = (value: unknown): Record =>
: {}
const asString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
-const asName = (value: unknown): string => {
- const name = asString(value)
- return name && !isInternalIdentifier(name) ? name : '未命名群成员'
+const asNullableString = (value: unknown): string | null => {
+ const result = asString(value)
+ return result || null
}
const asNumber = (value: unknown): number => {
const parsed = Number(value)
@@ -319,6 +221,8 @@ const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [
const asStrings = (value: unknown, limit = 20): string[] =>
asArray(value).map(asString).filter(Boolean).slice(0, limit)
+const normalizeName = (value: unknown): string => asString(value) || '未命名群成员'
+
const normalizeHeat = (value: unknown): ReportHeat => {
const heat = asString(value)
if (heat.includes('高')) return '高'
@@ -326,55 +230,8 @@ const normalizeHeat = (value: unknown): ReportHeat => {
return '中'
}
-const repairJsonLikeText = (value: string): string => {
- let repaired = ''
- let inString = false
- let escaped = false
-
- for (let index = 0; index < value.length; index += 1) {
- const char = value[index]
-
- if (!inString) {
- if (char === '"') inString = true
- repaired += char
- continue
- }
-
- if (escaped) {
- repaired += char
- escaped = false
- continue
- }
-
- if (char === '\\') {
- repaired += char
- escaped = true
- continue
- }
-
- if (char === '\n') {
- repaired += '\\n'
- continue
- }
-
- if (char === '\r') {
- continue
- }
-
- if (char === '"') {
- const nextNonSpace = value.slice(index + 1).match(/\S/)?.[0] || ''
- if (nextNonSpace && ![',', '}', ']', ':'].includes(nextNonSpace)) {
- repaired += '\\"'
- continue
- }
- inString = false
- }
-
- repaired += char
- }
-
- return repaired.replace(/,\s*([}\]])/g, '$1')
-}
+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 cleaned = raw
@@ -384,95 +241,479 @@ const extractJson = (raw: string): unknown => {
const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON')
- const jsonText = cleaned.slice(start, end + 1)
- try {
- return JSON.parse(jsonText)
- } catch (error) {
- try {
- return JSON.parse(repairJsonLikeText(jsonText))
- } catch {
- throw error
- }
- }
+ return JSON.parse(cleaned.slice(start, end + 1))
}
-export const parseGroupDailyReport = (
- raw: string,
- topSpeakers: ReportSpeakerRank[],
- activeTimeline: string
-): GroupDailyReport => {
- const root = asObject(extractJson(raw))
- const topics: ReportTopic[] = asArray(root.topics)
+const createSignature = (...parts: Array): string =>
+ parts
+ .map((part) => (part || '').replace(/\s+/g, ' ').trim())
+ .filter(Boolean)
+ .join('|')
+ .toLowerCase()
+
+const parseTopics = (root: Record): ReportTopic[] =>
+ asArray(root.topics)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
timeRange: asString(item.timeRange),
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),
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)
- .slice(0, 7)
- if (!topics.length) throw new Error('AI 日报中没有有效话题')
- const resources: ReportResource[] = asArray(root.resources)
+const parseResources = (root: Record): ReportResource[] =>
+ asArray(root.resources)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
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)
- const importantMessages: ReportImportantMessage[] = asArray(root.importantMessages)
+
+const parseImportantMessages = (root: Record): ReportImportantMessage[] =>
+ asArray(root.importantMessages)
.map((value) => {
const item = asObject(value)
return {
- sender: asName(item.sender),
+ sender: normalizeName(item.sender),
time: asString(item.time),
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)
- const quotes: ReportQuote[] = asArray(root.quotes)
+
+const parseQuotes = (root: Record): ReportQuote[] =>
+ asArray(root.quotes)
.map((value) => {
const item = asObject(value)
return {
messages: asArray(item.messages)
.map((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),
- 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)
- .slice(0, 3)
- const qa: ReportQuestionAnswer[] = asArray(root.qa)
+
+const parseQa = (root: Record): ReportQuestionAnswer[] =>
+ asArray(root.qa)
.map((value) => {
const item = asObject(value)
return {
question: asString(item.question),
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)
+const parseTodos = (root: Record): 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): 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): 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): 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): 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 = (
+ items: T[],
+ getSources: (item: T) => string[],
+ getSignature: (item: T) => string
+): T[] => {
+ const seenSources = new Set()
+ const seenSignatures = new Set()
+ 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 = (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> = {
+ 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 {
- overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
+ ...report,
+ mode,
+ hero,
topics,
resources,
importantMessages,
quotes,
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: {
topicHeat: topics.map((topic) => ({
topic: topic.title,
@@ -482,8 +723,12 @@ export const parseGroupDailyReport = (
topSpeakers: topSpeakers.map((speaker) => ({
name: speaker.name,
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)
}
diff --git a/src/shared/group-report.ts b/src/shared/group-report.ts
index f1119b6..288443c 100644
--- a/src/shared/group-report.ts
+++ b/src/shared/group-report.ts
@@ -1,4 +1,35 @@
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 {
title: string
@@ -7,13 +38,21 @@ export interface ReportTopic {
participants: string[]
summary: string
conclusion?: string
+ conclusions?: ReportTopicConclusion[]
keywords: string[]
+ sourceMessageIds?: string[]
+ image?: {
+ imageUrl?: string
+ note: string
+ sourceMessageIds?: string[]
+ } | null
}
export interface ReportResource {
title: string
description: string
sender?: string
+ sourceMessageIds?: string[]
}
export interface ReportImportantMessage {
@@ -21,22 +60,30 @@ export interface ReportImportantMessage {
time: string
content: string
note: string
+ sourceMessageIds?: string[]
+ importance?: number
+ confidence?: number
}
export interface ReportQuoteMessage {
sender: string
content: string
+ sourceMessageId?: string
}
export interface ReportQuote {
messages: ReportQuoteMessage[]
note: string
+ sourceMessageIds?: string[]
+ importance?: number
+ confidence?: number
}
export interface ReportQuestionAnswer {
question: string
answer: string
answerer?: string
+ sourceMessageIds?: string[]
}
export interface ReportTopicHeat {
@@ -49,19 +96,135 @@ export interface ReportSpeakerRank {
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 {
overview: string
+ mode?: ReportMode
+ hero?: ReportHero
topics: ReportTopic[]
resources: ReportResource[]
importantMessages: ReportImportantMessage[]
quotes: ReportQuote[]
qa: ReportQuestionAnswer[]
+ todos: ReportTodoItem[]
+ unresolved: ReportUnresolvedItem[]
+ storylines: ReportStoryline[]
+ reversals: ReportReversal[]
+ participantChains: ReportParticipantChain[]
analytics: {
topicHeat: ReportTopicHeat[]
activeTimeline: string
topSpeakers: ReportSpeakerRank[]
+ voiceLeaderboard: ReportVoiceLeaderboardItem[]
}
keywords: string[]
+ media: {
+ gallery: ReportMediaGalleryItem[]
+ voiceHighlights: ReportVoiceHighlight[]
+ funBadges: ReportFunBadge[]
+ }
+ summaryStats?: ReportSummaryStats
+ sectionMeta?: Partial>
}
export interface GroupReportMetadata {
@@ -70,18 +233,19 @@ export interface GroupReportMetadata {
dateRange: string
messageCount: number
activeUsers: number
+ imageCount?: number
+ voiceCount?: number
+ stickerCount?: number
+ mediaMessageCount?: number
timeSpan: string
generatedAt: string
recordNote: string
footerNote: string
heroParticipants: string[]
avatars: Record
- // === 新增(可选,向后兼容) ===
- /** 群昵称 / wxid / md5,服务端用来反推真头像(从 getGroupSnapshot) */
+ reportMode?: ReportMode
talker?: string
- /** 预留,与 /api/v1/chatlog 的 time 参数同格式 */
timeRange?: string
- /** 服务端写回,告知 client enrich 失败/部分缺失 */
warnings?: string[]
}