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}}
- 数据来源:WechatExplorer · 微信群聊记录
+ 数据来源:微信群聊记录
生成时间:{{GENERATED_AT}}
{{FOOTER_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( + `${label}` + ).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) => `${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) => ``).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) => `` + ) + .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} + ) : ( + (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()}> +
+
+ Generated Summary +
+
+
+ + {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[] }