feat: 更换日报功能模板

- ChatWindow 模型下拉框新增 deepseek-v4-pro /
  deepseek-v4-flash
  - 主进程 model 兜底值改为 deepseek-v4-flash
  - 同步更新 .env.example 默认模型
  - 修复旧 localStorage 中 gpt-5.5 等不支持的模型导致的
  400 报错
This commit is contained in:
电摇小子
2026-07-10 15:40:35 +08:00
parent b39cb362f7
commit d079d45279
11 changed files with 2325 additions and 323 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ VITE_DB_KEY=
# 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=
+3
View File
@@ -16,10 +16,13 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
- **实用信息与资源**: 提取分享的链接、资源等信息。
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
- **有趣对话或金句**: 收录群内的精彩对话。
- **问题与解答**: 整理群内的问答内容。
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云/关键词**: 可视化展示群聊关键词。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
+22 -3
View File
@@ -141,12 +141,31 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
"qa": [
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
],
"unresolved": [
{ "question": "待跟进问题", "owner": "相关人(可选)", "status": "待跟进", "note": "为什么还没结束" }
],
"storylines": [
{ "title": "剧情线", "stages": [{ "time": "10:12", "event": "提出问题" }], "result": "可选结果" }
],
"reversals": [
{ "topic": "某话题", "initialView": "最初判断", "finalView": "最终判断", "note": "可选说明" }
],
"participantChains": [
{ "topic": "某话题", "chain": ["A 提出", "B 补充", "C 收尾"], "note": "可选说明" }
],
"analytics": {
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
"activeTimeline": "10:00-12:00 为最活跃时段",
"topSpeakers": [{ "name": "张三", "count": 58 }]
"topSpeakers": [{ "name": "张三", "count": 58 }],
"voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
},
"keywords": ["高频词1", "高频词2"]
"keywords": ["高频词1", "高频词2"],
"hero": {
"headline": "一句抓重点的日报标题",
"summary": "一句概览",
"keyTakeaway": "最重要结论",
"pendingNote": "待跟进事项"
}
},
"metadata": {
"groupName": "技术交流",
@@ -333,4 +352,4 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
}
```
(视 MCP over HTTP 支持情况调整)
(视 MCP over HTTP 支持情况调整)
+389 -96
View File
@@ -27,13 +27,13 @@
.report {
width: 430px;
margin: 0 auto;
padding: 22px 14px 34px;
padding: 20px 14px 34px;
}
.hero,
.card,
.section {
.section,
.card {
background: #fff;
border-radius: 16px;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
@@ -41,25 +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;
}
.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;
@@ -76,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);
@@ -87,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 {
@@ -102,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: '';
@@ -120,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;
@@ -133,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;
@@ -158,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;
@@ -182,7 +269,6 @@
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
@@ -191,7 +277,6 @@
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.person-chip b {
max-width: 58px;
@@ -213,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;
@@ -248,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;
@@ -262,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;
@@ -292,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;
@@ -343,6 +522,9 @@
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank:last-child {
border-bottom: none;
}
.rank img {
width: 30px;
height: 30px;
@@ -395,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,
@@ -407,75 +640,135 @@
padding-left: 12px;
padding-right: 12px;
}
.stats {
gap: 6px;
}
.stat b {
font-size: 16px;
}
}
</style>
</head>
<body>
<body class="{{REPORT_MODE_CLASS}}">
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
<div class="mode-tag">{{REPORT_MODE_LABEL}}</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
</div>
<div class="hero-headline">
<b>{{HERO_HEADLINE}}</b>
<p>{{HERO_SUMMARY}}</p>
</div>
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
<div class="hero-inline-notes">
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
</div>
</header>
<section class="section topics">
<section class="section {{TOPICS_EMPTY_CLASS}}">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
{{TOPICS_MORE_NOTE}}
</section>
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
<section class="section {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息</div>
{{IMPORTANT_MESSAGES}}
{{MESSAGES_MORE_NOTE}}
</section>
<section class="section {{ACTIONS_EMPTY_CLASS}}">
<div class="section-title">待办事项和未解决问题</div>
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
{{ACTIONS_MORE_NOTE}}
</section>
<section class="section {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">今日名场面</div>
{{QUOTE_BLOCKS}}
{{QUOTES_MORE_NOTE}}
</section>
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
<div class="section-title">今日群数据</div>
<div class="data-grid">
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
</div>
</div>
</section>
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
<div class="section-title">关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
{{KEYWORDS_MORE_NOTE}}
</section>
<section class="section {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
{{RESOURCES_MORE_NOTE}}
</section>
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息汇总</div>
{{IMPORTANT_MESSAGES}}
</section>
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">有趣对话或金句</div>
{{QUOTE_BLOCKS}}
</section>
<section class="section qa {{QA_EMPTY_CLASS}}">
<section class="section {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
{{QA_MORE_NOTE}}
</section>
<section class="section analytics">
<div class="section-title">群内数据可视化</div>
{{HEAT_BARS}}
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
话唠榜 TOP5(基于已读取记录估算)
</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
</div>
<section class="section {{STORYLINES_EMPTY_CLASS}}">
<div class="section-title">今日剧情时间线</div>
{{STORYLINE_CARDS}}
{{STORYLINES_MORE_NOTE}}
</section>
<section class="section cloud">
<div class="section-title">词云/关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
<section class="section {{REVERSALS_EMPTY_CLASS}}">
<div class="section-title">群聊反转现场</div>
{{REVERSAL_CARDS}}
{{REVERSALS_MORE_NOTE}}
</section>
<section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div>
{{GALLERY_CARDS}}
{{GALLERY_MORE_NOTE}}
</section>
<section class="section {{VOICE_EMPTY_CLASS}}">
<div class="section-title">语音之最</div>
{{VOICE_CARDS}}
{{VOICE_MORE_NOTE}}
</section>
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
<div class="section-title">语音时长榜</div>
<div class="card">{{VOICE_RANK_CARDS}}</div>
</section>
<section class="section {{BADGES_EMPTY_CLASS}}">
<div class="section-title">今日临时人设</div>
<div class="badge-grid">{{BADGE_CARDS}}</div>
{{BADGES_MORE_NOTE}}
</section>
<section class="section {{CHAINS_EMPTY_CLASS}}">
<div class="section-title">话题参与链路</div>
{{CHAIN_CARDS}}
{{CHAINS_MORE_NOTE}}
</section>
<footer class="footer">
+409
View File
@@ -0,0 +1,409 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const templatePath = path.join(root, 'resources', 'mobile_daily_report.html')
const outputDir = path.join(os.tmpdir(), 'wechatexplorer-report-fixtures')
const escapeHtml = (value) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
const replacePlaceholder = (html, key, value) => html.replaceAll(`{{${key}}}`, value)
const avatarSvg = (label, color) =>
`data:image/svg+xml;base64,${Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
).toString('base64')}`
const localImagePath = '/Users/Wxw_/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/a969409112_d784/temp/RWTemp/2026-07/94ce24699a5a1d539c00a37ec8ace755.png'
const sampleImage = fs.existsSync(localImagePath)
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
: avatarSvg('图', '#dbeafe')
const avatars = {
阿宇: avatarSvg('宇', '#dcfce7'),
老周: avatarSvg('周', '#e0f2fe'),
小李: avatarSvg('李', '#fef3c7'),
'we water': avatarSvg('W', '#ede9fe'),
佩佩: avatarSvg('佩', '#fee2e2')
}
const heroNames = ['阿宇', '老周', '小李', 'we water']
const heroAvatars = heroNames
.map((name) => `<img src="${avatars[name]}" alt="${name}">`)
.join('')
const compactRequest = {
metadata: {
groupName: '技术交流',
reportDate: '2026-07-10',
dateRange: '2026-07-10 09:12-19:48',
messageCount: 382,
activeUsers: 47,
imageCount: 9,
voiceCount: 5,
stickerCount: 14,
mediaMessageCount: 28,
timeSpan: '11 h',
generatedAt: '2026-07-10 22:18',
recordNote: '基于示例数据生成的精简版日报',
footerNote: '精简版默认面向长图转发;无内容模块自动隐藏。',
heroParticipants: heroNames,
avatars,
reportMode: 'compact'
},
report: {
hero: {
headline: '接口排查和版本升级是今天主线',
summary: '白天主要围绕接口异常、升级节奏和上线安排展开,结论比争论更多,待跟进事项也比较集中。',
keyTakeaway: '大家确认本次异常更像缓存与配置问题,而不是服务端挂掉。',
pendingNote: '测试环境接口文档和回滚方案仍需补齐。',
statusLine: '今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决'
},
summaryStats: {
messageCount: 382,
activeUsers: 47,
topicCount: 4,
mediaCount: 28,
imageCount: 9,
voiceCount: 5,
stickerCount: 14,
conclusionCount: 3,
todoCount: 2,
unresolvedCount: 1
},
sectionMeta: {
hero: { enabled: true, importance: 1, confidence: 0.95, totalCount: 1, displayedCount: 1 },
topics: { enabled: true, importance: 0.98, confidence: 0.86, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
importantMessages: { enabled: true, importance: 0.95, confidence: 0.84, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
actions: { enabled: true, importance: 0.97, confidence: 0.81, totalCount: 5, displayedCount: 3, hiddenCount: 2 },
moments: { enabled: true, importance: 0.75, confidence: 0.75, totalCount: 3, displayedCount: 1, hiddenCount: 2 },
analytics: { enabled: true, importance: 0.8, confidence: 0.95, totalCount: 1, displayedCount: 1 },
keywords: { enabled: true, importance: 0.68, confidence: 0.92, totalCount: 16, displayedCount: 12, hiddenCount: 4 }
},
topics: [
{
title: 'GPT 接口异常排查',
timeRange: '09:20-11:05',
heat: '高',
summary: '上午先从接口超时和返回结构异常入手,几轮排查后,大家逐步把问题收敛到缓存与环境配置,而不是后端服务不可用。',
conclusions: [
{ text: '接口本身可用,异常更像本地缓存与环境变量冲突。' },
{ text: '先清缓存再复测,避免把旧响应误判成线上事故。' }
],
participants: ['阿宇', '老周', '小李'],
keywords: ['接口', '缓存', '环境变量'],
image: {
imageUrl: sampleImage,
note: '该图片引发 12 条回复。根据图片前后对话推断,这是一张帮助定位问题的截图。'
}
},
{
title: '版本升级节奏',
timeRange: '11:40-12:20',
heat: '中',
summary: '关于 React 版本是否立刻升级,讨论从“能不能升”转成“这周值不值得升”,最终倾向先补兼容性清单再动。',
conclusions: [{ text: '先列旧组件兼容清单,再决定升级窗口。' }],
participants: ['阿宇', 'we water', '佩佩'],
keywords: ['React', '升级', '兼容性']
},
{
title: '上线节奏与回滚准备',
timeRange: '15:10-16:05',
heat: '中',
summary: '下午讨论上线方案时,大家更关注回滚准备是否充分,最后把重点放在文档、监控和回滚路径补齐上。',
conclusions: [{ text: '上线前需要补一版简短回滚说明。' }],
participants: ['老周', '小李'],
keywords: ['上线', '回滚', '监控']
},
],
importantMessages: [
{ sender: '阿宇', time: '10:41', content: '先别回滚,接口能通。', note: '稳定了排查方向。' },
{ sender: '老周', time: '11:02', content: '像是缓存没清掉。', note: '把问题从服务端收敛到本地环境。' },
{ sender: '小李', time: '15:36', content: '上线前把回滚文档补一下。', note: '明确形成待办。' }
],
todos: [
{ task: '补测试环境接口文档', owner: '小李', topic: 'GPT 接口异常排查', note: '方便明天复测。' },
{ task: '整理回滚说明', owner: '老周', deadline: '今晚', topic: '上线节奏与回滚准备' }
],
unresolved: [
{ question: '缓存问题的根因是不是插件残留?', owner: '阿宇', status: '待跟进', lastDiscussedAt: '18:26', note: '目前只有推断,还没有最终证据。' }
],
quotes: [
{
messages: [
{ sender: '阿宇', content: '我以为接口炸了。' },
{ sender: '老周', content: '先别慌,先清缓存。' },
{ sender: '小李', content: '清完它居然真好了。' }
],
note: '从“要不要回滚”迅速切到“先做最小验证”,很像今天的群聊节奏。'
}
],
analytics: {
topSpeakers: [
{ name: '阿宇', count: 112 },
{ name: '老周', count: 78 },
{ name: '小李', count: 61 },
{ name: 'we water', count: 49 },
{ name: '佩佩', count: 33 }
],
activeTimeline: '09:00-09:59、10:00-10:59、15:00-15:59',
voiceLeaderboard: []
},
keywords: ['GPT', '接口', '缓存', '升级', '上线', '回滚', '监控', '兼容性', '截图', '文档', '测试环境', '复测'],
media: {
gallery: [],
voiceHighlights: [],
funBadges: []
},
resources: [],
qa: [],
storylines: [],
reversals: [],
participantChains: []
}
}
const fullRequest = JSON.parse(JSON.stringify(compactRequest))
fullRequest.metadata.recordNote = '基于示例数据生成的完整版日报'
fullRequest.metadata.reportMode = 'full'
fullRequest.report.sectionMeta = {
...fullRequest.report.sectionMeta,
resources: { enabled: true, importance: 0.58, confidence: 0.76, totalCount: 2, displayedCount: 2 },
qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 },
storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 },
reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 },
gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 },
voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 },
badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 },
chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
}
fullRequest.report.resources = [
{ title: '测试环境接口文档', description: '明天复测会直接用到的说明。', sender: '小李' },
{ title: '回滚说明草稿', description: '上线前确认回滚路径与负责人。', sender: '老周' }
]
fullRequest.report.qa = [
{ question: '今晚要不要升级 React', answer: '先不升级,先补兼容清单。', answerer: 'we water' },
{ question: '接口是不是服务端挂了?', answer: '不是,当前更像缓存与环境问题。', answerer: '老周' }
]
fullRequest.report.storylines = [
{
title: '接口异常排查线',
stages: [
{ time: '09:20', event: '阿宇提出接口异常。' },
{ time: '09:46', event: '老周建议先清缓存。' },
{ time: '10:41', event: '确认接口本身可通。' }
],
result: '初步定位为缓存与配置冲突。'
},
{
title: '版本升级讨论线',
stages: [
{ time: '11:40', event: '开始讨论是否本周升级。' },
{ time: '12:05', event: '补充兼容性与工期顾虑。' }
],
result: '今晚不升,先补兼容清单。'
}
]
fullRequest.report.reversals = [
{ topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
]
fullRequest.report.media = {
gallery: [
{
sender: '阿宇',
time: '09:52',
imageUrl: sampleImage,
note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
stats: '12 条后续消息 · 6 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
},
{
sender: '佩佩',
time: '17:14',
imageUrl: sampleImage,
note: '第二张图带起一轮轻松但有效的快速确认。',
stats: '5 条后续消息 · 3 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
}
],
voiceHighlights: [
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
],
funBadges: [
{ title: '高能输出王', owner: '阿宇', note: '今天一共发了 112 条消息。' },
{ title: '语音麦霸', owner: '老周', note: '语音总时长位列第一。' }
]
}
fullRequest.report.participantChains = [
{ topic: '接口异常排查', chain: ['阿宇 提出', '老周 收敛方向', '小李 验证', 'we water 定结论'], note: '比较典型的一条技术讨论链路。' }
]
fullRequest.report.analytics.voiceLeaderboard = [
{ sender: '老周', count: 3, durationSec: 97 },
{ sender: '阿宇', count: 2, durationSec: 54 }
]
async function renderRequest(request, targetBase) {
let html = await fs.readFile(templatePath, 'utf8')
const report = request.report
const metadata = request.metadata
const topicCards = report.topics
.map((topic) => {
const conclusions = (topic.conclusions || [])
.slice(0, 2)
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
.join('')
const image = topic.image?.imageUrl
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
: ''
return `<div class="card topic-card"><div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${topic.heat === '高' ? 'hot' : topic.heat === '低' ? 'blue' : ''}">${escapeHtml(topic.heat)}热</span></div><div class="topic-meta">${escapeHtml(topic.timeRange)}</div><p>${escapeHtml(topic.summary)}</p>${conclusions ? `<div class="topic-conclusions">${conclusions}</div>` : ''}${image}<div class="participants">${topic.participants.map((name) => `<span class="person-chip"><img src="${avatars[name] || avatarSvg(name[0], '#e5e7eb')}" alt=""><b>${escapeHtml(name)}</b></span>`).join('')}</div><div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div></div>`
})
.join('')
const importantMessages = report.importantMessages
.map((message) => `<div class="important-card"><img class="avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div><div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div></div>`)
.join('')
const todoCards = (report.todos || []).map((item) => `<div class="action-card todo-card"><b>${escapeHtml(item.task)}</b><div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
const unresolvedCards = (report.unresolved || []).map((item) => `<div class="action-card unresolved-card"><b>${escapeHtml(item.question)}</b><div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div><div class="action-note">${escapeHtml(item.note)}</div></div>`).join('')
const quoteBlocks = report.quotes.map((quote) => `<div class="chat-block">${quote.messages.map((message) => `<div class="chat-msg"><img class="chat-avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div><div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div></div></div>`).join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`).join('')
const rankItems = report.analytics.topSpeakers.map((speaker, index) => `<div class="rank"><img src="${avatars[speaker.name] || avatarSvg(speaker.name[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${speaker.count} 条</span></div>`).join('')
const cloudTags = report.keywords.map((word, index) => `<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`).join('')
const resourceItems = (report.resources || []).map((resource) => `<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`).join('')
const qaCards = (report.qa || []).map((item) => `<div class="qa-card"><b>Q${escapeHtml(item.question)}</b><div>A${escapeHtml(item.answer)}${item.answerer ? `${escapeHtml(item.answerer)}` : ''}</div></div>`).join('')
const storylineCards = (report.storylines || []).map((item) => `<div class="card storyline-card"><div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div><div class="storyline-steps">${item.stages.map((stage) => `<div class="storyline-step"><span>${escapeHtml(stage.time)}</span><b>${escapeHtml(stage.event)}</b></div>`).join('')}</div>${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}</div>`).join('')
const reversalCards = (report.reversals || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.topic)}</b><div>最初:${escapeHtml(item.initialView)}</div><div>后来:${escapeHtml(item.finalView)}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
const galleryCards = (report.media.gallery || []).map((item) => `<div class="gallery-card"><img class="gallery-image" src="${item.imageUrl}" alt=""><div class="gallery-body"><div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}<div class="important-text">${escapeHtml(item.note)}</div>${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}</div></div>`).join('')
const voiceCards = (report.media.voiceHighlights || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b><div>${escapeHtml(item.note)}</div></div>`).join('')
const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `<div class="rank"><img src="${avatars[item.sender] || avatarSvg(item.sender[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(item.sender)}</b><span>${item.count} 条 · ${item.durationSec} 秒</span></div>`).join('')
const badgeCards = (report.media.funBadges || []).map((item) => `<div class="badge-card"><span class="tag">${escapeHtml(item.title)}</span><b>${escapeHtml(item.owner)}</b><p>${escapeHtml(item.note)}</p></div>`).join('')
const chainCards = (report.participantChains || []).map((item) => `<div class="card chain-card"><div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div><div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}</div>`).join('')
const replaceMap = {
REPORT_TITLE: `${metadata.groupName}日报`,
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
GROUP_NAME: metadata.groupName,
DATE_RANGE: metadata.dateRange,
RECORD_NOTE: metadata.recordNote,
REPORT_MODE_LABEL: metadata.reportMode === 'full' ? '完整版' : '精简版',
HERO_HEADLINE: report.hero.headline,
HERO_SUMMARY: report.hero.summary,
HERO_STATUS_LINE: report.hero.statusLine || '',
HERO_STATUS_EMPTY_CLASS: report.hero.statusLine ? '' : 'empty-section',
HERO_TAKEAWAY: report.hero.keyTakeaway || '',
HERO_TAKEAWAY_EMPTY_CLASS: report.hero.keyTakeaway ? '' : 'empty-section',
HERO_PENDING: report.hero.pendingNote || '',
HERO_PENDING_EMPTY_CLASS: report.hero.pendingNote ? '' : 'empty-section',
HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(report.summaryStats.messageCount),
ACTIVE_USERS: String(report.summaryStats.activeUsers),
TOPIC_COUNT: String(report.summaryStats.topicCount),
MEDIA_COUNT: String(report.summaryStats.mediaCount),
TOPICS_EMPTY_CLASS: report.sectionMeta.topics?.enabled ? '' : 'empty-section',
TOPIC_CARDS: topicCards,
TOPICS_MORE_NOTE: report.sectionMeta.topics?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.topics.hiddenCount} 条内容,请在完整版中查看</div>` : '',
MESSAGES_EMPTY_CLASS: report.sectionMeta.importantMessages?.enabled ? '' : 'empty-section',
IMPORTANT_MESSAGES: importantMessages,
MESSAGES_MORE_NOTE: report.sectionMeta.importantMessages?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.importantMessages.hiddenCount} 条内容,请在完整版中查看</div>` : '',
ACTIONS_EMPTY_CLASS: report.sectionMeta.actions?.enabled ? '' : 'empty-section',
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
TODO_CARDS: todoCards,
UNRESOLVED_EMPTY_CLASS: report.unresolved.length ? '' : 'empty-section',
UNRESOLVED_CARDS: unresolvedCards,
ACTIONS_MORE_NOTE: report.sectionMeta.actions?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.actions.hiddenCount} 条内容,请在完整版中查看</div>` : '',
QUOTES_EMPTY_CLASS: report.sectionMeta.moments?.enabled ? '' : 'empty-section',
QUOTE_BLOCKS: quoteBlocks,
QUOTES_MORE_NOTE: report.sectionMeta.moments?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.moments.hiddenCount} 条内容,请在完整版中查看</div>` : '',
ANALYTICS_EMPTY_CLASS: '',
RANK_ITEMS: rankItems,
ACTIVITY_TIMELINE: report.analytics.activeTimeline,
CONCLUSION_COUNT: String(report.summaryStats.conclusionCount),
TODO_COUNT: String(report.summaryStats.todoCount),
UNRESOLVED_COUNT: String(report.summaryStats.unresolvedCount),
KEYWORDS_EMPTY_CLASS: report.sectionMeta.keywords?.enabled ? '' : 'empty-section',
CLOUD_TAGS: cloudTags,
KEYWORDS_MORE_NOTE: report.sectionMeta.keywords?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.keywords.hiddenCount} 个关键词,请在完整版中查看</div>` : '',
RESOURCES_EMPTY_CLASS: report.sectionMeta.resources?.enabled ? '' : 'empty-section',
RESOURCE_ITEMS: resourceItems,
RESOURCES_MORE_NOTE: '',
QA_EMPTY_CLASS: report.sectionMeta.qa?.enabled ? '' : 'empty-section',
QA_CARDS: qaCards,
QA_MORE_NOTE: '',
STORYLINES_EMPTY_CLASS: report.sectionMeta.storylines?.enabled ? '' : 'empty-section',
STORYLINE_CARDS: storylineCards,
STORYLINES_MORE_NOTE: '',
REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
REVERSAL_CARDS: reversalCards,
REVERSALS_MORE_NOTE: '',
GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section',
GALLERY_CARDS: galleryCards,
GALLERY_MORE_NOTE: '',
VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: '',
VOICE_RANK_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
VOICE_RANK_CARDS: voiceRankCards,
BADGES_EMPTY_CLASS: report.sectionMeta.badges?.enabled ? '' : 'empty-section',
BADGE_CARDS: badgeCards,
BADGES_MORE_NOTE: '',
CHAINS_EMPTY_CLASS: report.sectionMeta.chains?.enabled ? '' : 'empty-section',
CHAIN_CARDS: chainCards,
CHAINS_MORE_NOTE: '',
GENERATED_AT: metadata.generatedAt,
FOOTER_NOTE: metadata.footerNote
}
for (const [key, value] of Object.entries(replaceMap)) html = replacePlaceholder(html, key, value)
const htmlPath = path.join(outputDir, `${targetBase}.html`)
const pngPath = path.join(outputDir, `${targetBase}.png`)
await fs.ensureDir(outputDir)
await fs.writeFile(htmlPath, html, 'utf8')
const win = new BrowserWindow({
show: false,
width: 430,
height: 800,
frame: false,
backgroundColor: '#f3f5f7',
webPreferences: { sandbox: true }
})
await win.loadFile(htmlPath)
await win.webContents.executeJavaScript(`Promise.all([
document.fonts.ready,
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
}))
])`)
win.webContents.debugger.attach('1.3')
const metrics = await win.webContents.debugger.sendCommand('Page.getLayoutMetrics')
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
const height = Math.ceil(metrics.cssContentSize.height)
const screenshot = await win.webContents.debugger.sendCommand('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: true,
fromSurface: true,
clip: { x: 0, y: 0, width, height, scale: 1 }
})
await fs.writeFile(pngPath, Buffer.from(screenshot.data, 'base64'))
if (win.webContents.debugger.isAttached()) win.webContents.debugger.detach()
win.destroy()
return { htmlPath, pngPath, height }
}
app.whenReady().then(async () => {
const mode = process.argv.includes('--full') ? 'full' : 'compact'
const result =
mode === 'full'
? await renderRequest(fullRequest, 'full-fixture')
: await renderRequest(compactRequest, 'compact-fixture')
console.log(JSON.stringify({ mode, result, outputDir }, null, 2))
app.quit()
})
+212 -18
View File
@@ -140,6 +140,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<typeof request.report.sectionMeta>
) => request.report.sectionMeta?.[key]
const sectionClass = (
request: GroupReportExportRequest,
key: keyof NonNullable<typeof request.report.sectionMeta>,
hasContent: boolean
): string => (sectionMeta(request, key)?.enabled && hasContent ? '' : 'empty-section')
const overflowNote = (
request: GroupReportExportRequest,
key: keyof NonNullable<typeof request.report.sectionMeta>
): string => {
const meta = sectionMeta(request, key)
if (request.metadata.reportMode !== 'compact' || !meta?.hiddenCount) return ''
return `<div class="section-more">另有 ${meta.hiddenCount} 条内容,请在完整版中查看</div>`
}
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
const { report, metadata } = request
const avatarNames = new Set<string>(metadata.heroParticipants)
@@ -149,6 +172,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
quote.messages.forEach((message) => avatarNames.add(message.sender))
)
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
const avatars = new Map<string, string>()
await Promise.all(
@@ -170,7 +196,21 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
<p>${escapeHtml(topic.summary)}</p>
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
${
topic.conclusions?.length
? `<div class="topic-conclusions">${topic.conclusions
.slice(0, 2)
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
.join('')}</div>`
: topic.conclusion
? `<div class="topic-conclusions"><div class="topic-conclusion">${escapeHtml(topic.conclusion)}</div></div>`
: ''
}
${
topic.image?.imageUrl
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
: ''
}
<div class="participants">${topic.participants
.slice(0, 5)
.map(
@@ -215,18 +255,104 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
)
.join('')
const qaCards = report.qa
const todoCards = (report.todos || [])
.map(
(item) =>
`<div class="qa-card"><b>Q${escapeHtml(item.question)}</b><div>A${escapeHtml(item.answer)}${item.answerer ? `${escapeHtml(item.answerer)}` : ''}</div></div>`
(item) => `<div class="action-card todo-card">
<b>${escapeHtml(item.task)}</b>
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
</div>`
)
.join('')
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
const heatBars = report.analytics.topicHeat
const unresolvedCards = (report.unresolved || [])
.map(
(item) =>
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
(item) => `<div class="action-card unresolved-card">
<b>${escapeHtml(item.question)}</b>
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
<div class="action-note">${escapeHtml(item.note)}</div>
</div>`
)
.join('')
const storylineCards = (report.storylines || [])
.map(
(item) => `<div class="card storyline-card">
<div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div>
<div class="storyline-steps">${item.stages
.map(
(stage) => `<div class="storyline-step">
<span>${escapeHtml(stage.time || '--:--')}</span>
<b>${escapeHtml(stage.event)}</b>
</div>`
)
.join('')}</div>
${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}
</div>`
)
.join('')
const reversalCards = (report.reversals || [])
.map(
(item) => `<div class="qa-card">
<b>${escapeHtml(item.topic)}</b>
<div>最初:${escapeHtml(item.initialView)}</div>
<div>后来:${escapeHtml(item.finalView)}</div>
${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}
</div>`
)
.join('')
const chainCards = (report.participantChains || [])
.map(
(item) => `<div class="card chain-card">
<div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div>
<div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>
${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}
</div>`
)
.join('')
const galleryCards = (report.media?.gallery || [])
.map(
(item) => `<div class="gallery-card">
<img class="gallery-image" src="${item.imageUrl}" alt="群聊图片">
<div class="gallery-body">
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}
<div class="important-text">${escapeHtml(item.note)}</div>
${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}
</div>
</div>`
)
.join('')
const voiceCards = (report.media?.voiceHighlights || [])
.map(
(item) => `<div class="qa-card">
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
<div>${escapeHtml(item.note)}</div>
</div>`
)
.join('')
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
.map(
(item, index) => `<div class="rank">
<img src="${avatar(item.sender)}" alt="">
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
<span>${item.count} 条 · ${item.durationSec} 秒</span>
</div>`
)
.join('')
const badgeCards = (report.media?.funBadges || [])
.map(
(item) => `<div class="badge-card">
<span class="tag">${escapeHtml(item.title)}</span>
<b>${escapeHtml(item.owner)}</b>
<p>${escapeHtml(item.note)}</p>
</div>`
)
.join('')
@@ -246,30 +372,98 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
)
.join('')
const qaCards = report.qa
.map(
(item) =>
`<div class="qa-card"><b>Q${escapeHtml(item.question)}</b><div>A${escapeHtml(item.answer)}${item.answerer ? `${escapeHtml(item.answerer)}` : ''}</div></div>`
)
.join('')
const summaryStats = report.summaryStats || {
messageCount: metadata.messageCount,
activeUsers: metadata.activeUsers,
topicCount: report.topics.length,
mediaCount: metadata.mediaMessageCount || 0,
imageCount: metadata.imageCount || 0,
voiceCount: metadata.voiceCount || 0,
stickerCount: metadata.stickerCount || 0,
conclusionCount: 0,
todoCount: report.todos.length,
unresolvedCount: report.unresolved.length
}
let html = await fs.readFile(templatePath(), 'utf8')
const values: Record<string, string> = {
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange),
RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()),
RECORD_NOTE: escapeHtml(metadata.recordNote),
REPORT_MODE_LABEL: escapeHtml(modeLabel(metadata.reportMode)),
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''),
HERO_PENDING: escapeHtml(report.hero?.pendingNote || ''),
HERO_STATUS_LINE: escapeHtml(report.hero?.statusLine || ''),
HERO_STATUS_EMPTY_CLASS: report.hero?.statusLine ? '' : 'empty-section',
HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
HERO_AVATARS: heroAvatars,
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)
}
@@ -332,7 +526,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 html = await renderReportHtml(request)
+1 -1
View File
@@ -157,7 +157,7 @@ app.whenReady().then(async () => {
) => {
// @ts-ignore: vite env
const apiKey = options?.apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY
const model = options?.model || import.meta.env.VITE_AI_MODEL || 'deepseek-chat'
const model = options?.model || import.meta.env.VITE_AI_MODEL || 'deepseek-v4-flash'
const baseURL =
options?.baseURL || import.meta.env.VITE_AI_BASE_URL || 'https://api.deepseek.com'
+42 -4
View File
@@ -8,6 +8,7 @@ import {
GROUP_REPORT_SYSTEM_PROMPT,
parseGroupDailyReport
} from '../utils/group-report'
import type { ReportMode } from '../../../shared/group-report'
interface ChatWindowProps {
contact: Contact | null
@@ -81,9 +82,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const [baseURL, setBaseURL] = useState(
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
)
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-v4-flash')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const [reportMode, setReportMode] = useState<ReportMode>(
() => (localStorage.getItem('group_report_mode') as ReportMode) || 'compact'
)
const handleSaveSettings = (): void => {
if (!summaryMessageTypes.length) {
@@ -93,6 +97,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
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()
}
@@ -240,7 +245,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
const input = buildGroupReportInput(reportMessages, contact, isGroupChat)
const input = await buildGroupReportInput(reportMessages, contact, isGroupChat, reportMode)
console.log('🚀 ~ AIChat ~ input:', input)
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
const result = await window.api.aiChat(
@@ -252,7 +257,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
const report = parseGroupDailyReport(
result.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard,
input.metadata,
input.media
)
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
throw new Error(exported.error || '日报文件生成失败')
@@ -548,6 +560,31 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
))}
</div>
</div>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-date-options">
<label className={reportMode === 'compact' ? 'selected' : ''}>
<input
type="radio"
name="report-mode"
value="compact"
checked={reportMode === 'compact'}
onChange={() => setReportMode('compact')}
/>
</label>
<label className={reportMode === 'full' ? 'selected' : ''}>
<input
type="radio"
name="report-mode"
value="full"
checked={reportMode === 'full'}
onChange={() => setReportMode('full')}
/>
</label>
</div>
</div>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-type-options">
@@ -570,7 +607,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onChange={(e) => setModel(e.target.value)}
style={{ width: '100%', padding: '8px' }}
>
<option value="deepseek-chat">DeepSeek Chat</option>
<option value="deepseek-v4-pro">DeepSeek V4 Pro</option>
<option value="deepseek-v4-flash">DeepSeek V4 Flash</option>
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4o-mini">GPT-4o Mini</option>
<option value="gpt-4-turbo">GPT-4 Turbo</option>
@@ -0,0 +1,490 @@
import { Contact, Message } from '../../../shared/types'
import {
GroupDailyReport,
GroupReportMetadata,
ReportFunBadge,
ReportMediaGalleryItem,
ReportMode,
ReportSpeakerRank,
ReportVoiceHighlight,
ReportVoiceLeaderboardItem
} from '../../../shared/group-report'
export interface GroupReportTranscriptRow {
id: string
datetime: string
timestamp: number
sender: string
content: string
avatar?: string
}
export interface GroupReportFactsSnapshot {
metadata: GroupReportMetadata
transcriptRows: GroupReportTranscriptRow[]
topSpeakers: ReportSpeakerRank[]
activeTimeline: string
media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[]
factsPrompt: string
}
export const isInternalIdentifier = (value: string): boolean =>
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
export const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
if (message.from === 'assistant') {
const ownGroupNickname = message.name?.trim()
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
return ownGroupNickname
}
return '我'
}
const candidate = isGroup ? message.name : contact?.m_nsNickName
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
return candidate
}
export const summaryContent = (message: Message): string => {
const data = message.contentData
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
switch (data.type) {
case 'image':
return '[图片]'
case 'sticker':
return '[表情]'
case 'voice':
return `[语音${data.duration ? ` ${data.duration}` : ''}]`
case 'share':
return `[分享] ${data.title}${data.des ? `${data.des}` : ''}`
case 'quote': {
const reply = data.title || data.content || message.content || '[回复]'
const quotedSender =
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
return `${reply}(引用 ${quotedSender}${data.quotedContent || `[引用${data.quotedType || '消息'}]`}`
}
case 'location':
return `[位置] ${data.poiname || data.label || '位置消息'}`
case 'card':
return `[名片] ${data.nickname || '微信名片'}`
case 'voip':
return `[通话] ${data.status}${data.duration ? `${data.duration}` : ''}`
case 'system':
case 'text':
return data.content
case 'unknown':
return `[${message.type || '未知消息'}]`
}
return `[${message.type || '消息'}]`
}
export const parseTimestamp = (message: Message): number => {
const value = new Date(message.datetime).getTime()
return Number.isFinite(value) ? value : 0
}
export const localDate = (timestamp: number): string => {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export const localTime = (timestamp: number): string =>
new Date(timestamp).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit'
})
export const resolveVoiceDuration = (message: Message): number => {
const fromData = message.contentData?.type === 'voice' ? message.contentData.duration : undefined
return Math.max(0, Number(fromData ?? message.voiceDuration ?? 0) || 0)
}
const truncate = (value: string, max = 48): string =>
value.length > max ? `${value.slice(0, max - 1)}` : value
const buildImageContext = (
messages: Message[],
index: number,
contact: Contact | null,
isGroup: boolean
): {
note: string
stats: string
responseCount: number
participantCount: number
snippets: string[]
} => {
const baseTime = parseTimestamp(messages[index])
const participants = new Set<string>()
const snippets: string[] = []
let responseCount = 0
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
const candidate = messages[offset]
const candidateTime = parseTimestamp(candidate)
if (baseTime && candidateTime && candidateTime - baseTime > 20 * 60 * 1000) break
if (candidate.type === '系统消息' || candidate.from === 'system') continue
const sender = summarySender(candidate, contact, isGroup)
const sameSender = sender === summarySender(messages[index], contact, isGroup)
const content = summaryContent(candidate)
if (!sameSender) {
responseCount += 1
participants.add(sender)
}
if (
snippets.length < 3 &&
!content.startsWith('[图片]') &&
!content.startsWith('[表情]') &&
!content.startsWith('[语音')
) {
snippets.push(truncate(content, 28))
}
}
const note = snippets.length
? `图片发出后,群里接着聊到:${snippets.join(' / ')}`
: responseCount > 0
? '图片发出后引发了一波接续讨论。'
: '这张图片更多像是一次轻量分享,没有形成长链路讨论。'
const statsParts: string[] = []
if (responseCount > 0) statsParts.push(`${responseCount} 条后续消息`)
if (participants.size > 0) statsParts.push(`${participants.size} 人接话`)
if (!statsParts.length) statsParts.push('讨论热度较低')
return {
note,
stats: statsParts.join(' · '),
responseCount,
participantCount: participants.size,
snippets
}
}
const buildMediaSection = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
topSpeakersMap: Map<string, number>
): Promise<{
media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[]
}> => {
const rawImageCandidates = messages
.map((message, index) => {
if (message.contentData?.type !== 'image') return null
const sender = summarySender(message, contact, isGroup)
const context = buildImageContext(messages, index, contact, isGroup)
return {
sourceMessageIds: [message.id],
md5: message.contentData.md5,
datName: message.contentData.datName,
sessionId: message.sessionId,
sender,
time: localTime(parseTimestamp(message)),
note: context.note,
stats: context.stats,
replyCount: context.responseCount,
score: context.responseCount * 3 + context.participantCount * 2 + 1
}
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((left, right) => right.score - left.score)
.slice(0, 6)
const imageCandidates = await Promise.all(
rawImageCandidates.map(async (item) => {
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
if (!result.success || !result.data?.startsWith('data:image/')) return null
return {
sender: item.sender,
time: item.time,
imageUrl: result.data,
note: item.note,
stats: item.stats,
inferenceLabel: '基于图片后的聊天上下文推断',
sourceMessageIds: item.sourceMessageIds,
replyCount: item.replyCount,
score: item.score
}
})
)
const gallery: ReportMediaGalleryItem[] = imageCandidates
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((left, right) => right.score - left.score)
.slice(0, 4)
.map(({ score: _score, ...item }) => item)
const voiceMessages = messages
.filter((message) => message.contentData?.type === 'voice')
.map((message) => ({
sender: summarySender(message, contact, isGroup),
duration: resolveVoiceDuration(message),
time: localTime(parseTimestamp(message))
}))
const voiceTotals = new Map<string, { count: number; duration: number }>()
for (const item of voiceMessages) {
const current = voiceTotals.get(item.sender) || { count: 0, duration: 0 }
current.count += 1
current.duration += item.duration
voiceTotals.set(item.sender, current)
}
const voiceLeaderboard: ReportVoiceLeaderboardItem[] = Array.from(voiceTotals.entries())
.map(([sender, value]) => ({
sender,
count: value.count,
durationSec: value.duration
}))
.sort((left, right) => right.durationSec - left.durationSec || right.count - left.count)
.slice(0, 5)
let bestStreak: { sender: string; count: number; duration: number; time: string } | null = null
let currentStreak: { sender: string; count: number; duration: number; time: string } | null = null
for (const message of messages) {
if (message.contentData?.type !== 'voice') {
currentStreak = null
continue
}
const sender = summarySender(message, contact, isGroup)
const duration = resolveVoiceDuration(message)
const time = localTime(parseTimestamp(message))
if (currentStreak && currentStreak.sender === sender) {
currentStreak.count += 1
currentStreak.duration += duration
} else {
currentStreak = { sender, count: 1, duration, time }
}
if (!bestStreak || currentStreak.count > bestStreak.count) {
bestStreak = { ...currentStreak }
}
}
const voiceHighlights: ReportVoiceHighlight[] = []
if (voiceLeaderboard[0]) {
voiceHighlights.push({
title: '语音输出王',
sender: voiceLeaderboard[0].sender,
note: `共发送 ${voiceLeaderboard[0].count} 条语音,累计 ${voiceLeaderboard[0].durationSec} 秒。`
})
}
if (bestStreak && bestStreak.count >= 2) {
voiceHighlights.push({
title: '连续发言时刻',
sender: bestStreak.sender,
note: `${bestStreak.time} 连发 ${bestStreak.count} 条语音,共 ${bestStreak.duration} 秒。`
})
}
const funBadges: ReportFunBadge[] = []
const topSpeaker = Array.from(topSpeakersMap.entries()).sort((left, right) => right[1] - left[1])[0]
if (topSpeaker) {
funBadges.push({
title: '高能输出王',
owner: topSpeaker[0],
note: `今天一共发了 ${topSpeaker[1]} 条消息。`
})
}
if (gallery[0]) {
funBadges.push({
title: '图片话题王',
owner: gallery[0].sender,
note: `${gallery[0].time} 的图片带动了最明显的一轮讨论。`
})
}
if (voiceLeaderboard[0]) {
funBadges.push({
title: '语音麦霸',
owner: voiceLeaderboard[0].sender,
note: `语音总时长暂居第一,适合放进“今日声音档案”。`
})
}
return {
media: {
gallery,
voiceHighlights: voiceHighlights.slice(0, 2),
funBadges: funBadges.slice(0, 3)
},
voiceLeaderboard
}
}
const collectQuestionCandidates = (
messages: Message[],
contact: Contact | null,
isGroup: boolean
): string[] =>
messages
.map((message) => ({
id: message.id,
sender: summarySender(message, contact, isGroup),
content: summaryContent(message)
}))
.filter((item) => /[?]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么'))
.slice(-6)
.map((item) => `${item.sender}${item.id}):${truncate(item.content, 32)}`)
const collectReplyFacts = (messages: Message[], contact: Contact | null, isGroup: boolean): string[] =>
messages
.filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender)
.slice(0, 10)
.map((message) => {
const sender = summarySender(message, contact, isGroup)
const quotedSender =
message.contentData?.type === 'quote' && message.contentData.quotedSender
? message.contentData.quotedSender
: '群成员'
return `${sender} 回复了 ${quotedSender}`
})
export const buildGroupReportFacts = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
reportMode: ReportMode
): Promise<GroupReportFactsSnapshot> => {
const transcriptRows = messages.map((message) => ({
id: message.id,
datetime: message.datetime,
timestamp: parseTimestamp(message),
sender: summarySender(message, contact, isGroup),
content: summaryContent(message),
avatar: message.img
}))
let firstTimestamp = Number.POSITIVE_INFINITY
let lastTimestamp = Number.NEGATIVE_INFINITY
for (const row of transcriptRows) {
if (!Number.isFinite(row.timestamp)) continue
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
}
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
const speakerCounts = new Map<string, number>()
const hourCounts = new Map<number, number>()
const avatars: Record<string, string | undefined> = {}
let imageCount = 0
let stickerCount = 0
let voiceCount = 0
let voiceDurationSec = 0
for (const message of messages) {
const sender = summarySender(message, contact, isGroup)
const timestamp = parseTimestamp(message)
speakerCounts.set(sender, (speakerCounts.get(sender) || 0) + 1)
if (Number.isFinite(timestamp)) {
const hour = new Date(timestamp).getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
if (message.img && !avatars[sender]) avatars[sender] = message.img
if (message.contentData?.type === 'image') imageCount += 1
if (message.contentData?.type === 'sticker') stickerCount += 1
if (message.contentData?.type === 'voice') {
voiceCount += 1
voiceDurationSec += resolveVoiceDuration(message)
}
}
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 5)
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 4)
.sort((left, right) => left.hour - right.hour)
.map(
({ hour, count }) =>
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59${count}条)`
)
.join('、')
const startDate = localDate(firstTimestamp)
const endDate = localDate(lastTimestamp)
const sameDay = startDate === endDate
const dateRange = sameDay
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
: `${startDate} ${localTime(firstTimestamp)}${endDate} ${localTime(lastTimestamp)}`
const durationMs = Math.max(0, lastTimestamp - firstTimestamp)
const durationHours = durationMs / 3600000
const timeSpan = (() => {
if (sameDay) {
if (durationHours < 1) {
const minutes = Math.max(1, Math.round(durationMs / 60000))
return `${minutes} min`
}
const hours = Math.max(1, Math.ceil(durationHours))
return `${hours} h`
}
const days = Math.max(1, Math.ceil(durationMs / 86400000))
return `${days} d`
})()
const contactName = contact?.m_nsNickName || ''
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
const metadata: GroupReportMetadata = {
groupName,
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
dateRange,
messageCount: transcriptRows.length,
activeUsers: speakerCounts.size,
imageCount,
voiceCount,
stickerCount,
mediaMessageCount: imageCount + voiceCount + stickerCount,
timeSpan,
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
recordNote: `基于当前已加载的 ${transcriptRows.length} 条记录`,
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。',
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
avatars,
reportMode
}
const { media, voiceLeaderboard } = await buildMediaSection(messages, contact, isGroup, speakerCounts)
const factsPrompt = [
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
activeTimeline ? `活跃时段:${activeTimeline}` : '',
media.gallery.length
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats}`).join('')}`
: '',
voiceLeaderboard.length
? `语音榜:${voiceLeaderboard
.slice(0, 3)
.map((item) => `${item.sender} ${item.count} 条 / ${item.durationSec}`)
.join('')}`
: '',
collectQuestionCandidates(messages, contact, isGroup).length
? `疑似待跟进问题:${collectQuestionCandidates(messages, contact, isGroup).join('')}`
: '',
collectReplyFacts(messages, contact, isGroup).length
? `回复关系样本:${collectReplyFacts(messages, contact, isGroup).join('')}`
: ''
]
.filter(Boolean)
.join('\n')
return {
metadata,
transcriptRows,
topSpeakers,
activeTimeline,
media,
voiceLeaderboard,
factsPrompt
}
}
+588 -196
View File
@@ -1,216 +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'
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)
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<ReportMode, string> = {
compact: '精简版(推荐)',
full: '完整版'
}
interface ReportModeConfig {
maxTopics: number
maxImportantMessages: number
maxTodos: number
maxUnresolved: number
maxQuotes: number
maxQuoteMessages: number
maxKeywords: number
maxStorylines: number
maxReversals: number
maxChains: number
maxGallery: number
maxVoiceHighlights: number
maxBadges: number
maxResources: number
maxQa: number
topicSummaryLength: number
noteLength: number
enabledSections: ReportSectionKey[]
}
const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
compact: {
maxTopics: 3,
maxImportantMessages: 3,
maxTodos: 4,
maxUnresolved: 3,
maxQuotes: 1,
maxQuoteMessages: 4,
maxKeywords: 12,
maxStorylines: 0,
maxReversals: 0,
maxChains: 0,
maxGallery: 0,
maxVoiceHighlights: 0,
maxBadges: 0,
maxResources: 0,
maxQa: 0,
topicSummaryLength: 100,
noteLength: 72,
enabledSections: ['hero', 'topics', 'importantMessages', 'actions', 'moments', 'analytics', 'keywords']
},
full: {
maxTopics: 6,
maxImportantMessages: 6,
maxTodos: 8,
maxUnresolved: 5,
maxQuotes: 3,
maxQuoteMessages: 5,
maxKeywords: 15,
maxStorylines: 2,
maxReversals: 2,
maxChains: 3,
maxGallery: 4,
maxVoiceHighlights: 2,
maxBadges: 3,
maxResources: 4,
maxQa: 4,
topicSummaryLength: 140,
noteLength: 96,
enabledSections: [
'hero',
'topics',
'importantMessages',
'actions',
'moments',
'analytics',
'keywords',
'resources',
'qa',
'storylines',
'reversals',
'gallery',
'voices',
'badges',
'chains'
]
}
}
export const buildGroupReportInput = async (
messages: Message[],
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<GroupReportInput> => {
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<string, number>()
const hourCounts = new Map<number, number>()
const avatars: Record<string, string | undefined> = {}
for (const row of rows) {
speakerCounts.set(row.sender, (speakerCounts.get(row.sender) || 0) + 1)
if (Number.isFinite(row.timestamp)) {
const hour = new Date(row.timestamp).getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
if (row.avatar && !avatars[row.sender]) avatars[row.sender] = row.avatar
}
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 5)
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 4)
.sort((left, right) => left.hour - right.hour)
.map(
({ hour, count }) =>
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59${count}条)`
)
.join('、')
const startDate = localDate(firstTimestamp)
const endDate = localDate(lastTimestamp)
const sameDay = startDate === endDate
const dateRange = sameDay
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
: `${startDate} ${localTime(firstTimestamp)}${endDate} ${localTime(lastTimestamp)}`
// 模板"持续时长"格子:首条到末条消息的时长,紧凑半角格式
const 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: `基于当前已加载的 ${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<string, unknown> =>
@@ -219,9 +209,9 @@ const asObject = (value: unknown): Record<string, unknown> =>
: {}
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)
@@ -231,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 '高'
@@ -238,6 +230,9 @@ const normalizeHeat = (value: unknown): ReportHeat => {
return '中'
}
const truncate = (value: string, max: number): string =>
value.length > max ? `${value.slice(0, Math.max(1, max - 1))}` : value
const extractJson = (raw: string): unknown => {
const cleaned = raw
.trim()
@@ -249,83 +244,476 @@ const extractJson = (raw: string): unknown => {
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 | null | undefined>): string =>
parts
.map((part) => (part || '').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('|')
.toLowerCase()
const parseTopics = (root: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): ReportTodoItem[] =>
asArray(root.todos)
.map((value) => {
const item = asObject(value)
return {
task: asString(item.task),
owner: item.owner === null ? null : item.owner ? normalizeName(item.owner) : undefined,
deadline: item.deadline === null ? null : asNullableString(item.deadline),
topic: item.topic === null ? null : asNullableString(item.topic),
note: asString(item.note),
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
importance: asNumber(item.importance),
confidence: asNumber(item.confidence)
}
})
.filter((item) => item.task)
const parseUnresolved = (root: Record<string, unknown>): ReportUnresolvedItem[] =>
asArray(root.unresolved)
.map((value) => {
const item = asObject(value)
const status = asString(item.status)
const normalizedStatus: ReportUnresolvedItem['status'] =
status === '暂未回答' || status === '进行中' || status === '待跟进' ? status : '待跟进'
return {
question: asString(item.question),
owner: item.owner ? normalizeName(item.owner) : undefined,
status: normalizedStatus,
note: asString(item.note),
lastDiscussedAt: item.lastDiscussedAt === null ? null : asNullableString(item.lastDiscussedAt),
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
importance: asNumber(item.importance),
confidence: asNumber(item.confidence)
}
})
.filter((item) => item.question && item.note)
const parseStorylines = (root: Record<string, unknown>): ReportStoryline[] =>
asArray(root.storylines)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
stages: asArray(item.stages)
.map((stageValue) => {
const stage = asObject(stageValue)
return {
time: asString(stage.time),
event: asString(stage.event),
sourceMessageIds: asStrings(stage.sourceMessageIds, 6)
}
})
.filter((stage) => stage.event),
result: asString(item.result),
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
}
})
.filter((item) => item.title && item.stages.length)
const parseReversals = (root: Record<string, unknown>): ReportReversal[] =>
asArray(root.reversals)
.map((value) => {
const item = asObject(value)
return {
topic: asString(item.topic),
initialView: asString(item.initialView),
finalView: asString(item.finalView),
note: asString(item.note),
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
}
})
.filter((item) => item.topic && item.initialView && item.finalView)
const parseParticipantChains = (root: Record<string, unknown>): ReportParticipantChain[] =>
asArray(root.participantChains)
.map((value) => {
const item = asObject(value)
return {
topic: asString(item.topic),
chain: asStrings(item.chain, 5),
note: asString(item.note),
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
}
})
.filter((item) => item.topic && item.chain.length)
const scoreByHeat = (heat: ReportHeat): number => (heat === '高' ? 0.95 : heat === '中' ? 0.75 : 0.55)
const scoreByCount = (count: number, max = 5): number => Math.min(1, Math.max(0.3, count / max))
const dedupeItems = <T>(
items: T[],
getSources: (item: T) => string[],
getSignature: (item: T) => string
): T[] => {
const seenSources = new Set<string>()
const seenSignatures = new Set<string>()
const result: T[] = []
for (const item of items) {
const signature = getSignature(item)
const sources = getSources(item).filter(Boolean)
const sourceOverlap = sources.some((source) => seenSources.has(source))
if (signature && seenSignatures.has(signature)) continue
if (sourceOverlap && sources.length) continue
result.push(item)
if (signature) seenSignatures.add(signature)
for (const source of sources) seenSources.add(source)
}
return result
}
const sortByScore = <T>(items: T[], scorer: (item: T) => number): T[] =>
[...items].sort((left, right) => scorer(right) - scorer(left))
const buildSectionMeta = (
enabled: boolean,
displayedCount: number,
totalCount: number,
importance: number,
confidence: number
): ReportSectionMeta => ({
enabled: enabled && displayedCount > 0,
importance,
confidence,
totalCount,
displayedCount,
hiddenCount: Math.max(0, totalCount - displayedCount)
})
const clampTopics = (topics: ReportTopic[], config: ReportModeConfig): ReportTopic[] =>
topics.slice(0, config.maxTopics).map((topic) => ({
...topic,
summary: truncate(topic.summary, config.topicSummaryLength),
conclusions: (topic.conclusions || [])
.slice(0, 2)
.map((entry) => ({ ...entry, text: truncate(entry.text, config.noteLength) })),
conclusion: topic.conclusion ? truncate(topic.conclusion, config.noteLength) : topic.conclusion,
keywords: topic.keywords.slice(0, 3)
}))
const attachHighImpactImage = (
topics: ReportTopic[],
gallery: GroupDailyReport['media']['gallery']
): ReportTopic[] => {
if (!gallery.length) return topics
const [firstImage, ...rest] = gallery
const nextTopics = topics.map((topic, index) =>
index === 0 && firstImage.replyCount && firstImage.replyCount >= 3
? {
...topic,
image: {
imageUrl: firstImage.imageUrl,
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
sourceMessageIds: firstImage.sourceMessageIds
}
}
: topic
)
gallery.splice(0, rest.length >= 0 ? 1 : 0)
return nextTopics
}
const postProcessReport = (
report: GroupDailyReport,
mode: ReportMode,
metadata: GroupReportMetadata
): GroupDailyReport => {
const config = REPORT_MODE_CONFIG[mode]
const topicsScored = sortByScore(report.topics, (item) => scoreByHeat(item.heat))
const topicsDeduped = dedupeItems(
topicsScored,
(item) => item.sourceMessageIds || [],
(item) => createSignature(item.title, item.summary)
)
let gallery = [...report.media.gallery]
const topics = attachHighImpactImage(clampTopics(topicsDeduped, config), gallery)
const importantMessagesRaw = sortByScore(report.importantMessages, (item) =>
Math.max(item.importance || 0, item.confidence || 0.6)
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.55 : true))
const importantMessages = dedupeItems(
importantMessagesRaw,
(item) => item.sourceMessageIds || [],
(item) => createSignature(item.content, item.note)
)
.slice(0, config.maxImportantMessages)
.map((item) => ({
...item,
content: truncate(item.content, config.noteLength),
note: truncate(item.note, config.noteLength)
}))
const todosRaw = sortByScore(report.todos, (item) =>
Math.max(item.importance || 0.6, item.confidence || 0.5)
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.5 : true))
const todos = dedupeItems(
todosRaw,
(item) => item.sourceMessageIds || [],
(item) => createSignature(item.task, item.owner || '', item.deadline || '')
)
.slice(0, config.maxTodos)
.map((item) => ({
...item,
task: truncate(item.task, config.noteLength),
note: item.note ? truncate(item.note, config.noteLength) : item.note
}))
const unresolvedRaw = sortByScore(report.unresolved, (item) =>
Math.max(item.importance || 0.6, item.confidence || 0.5)
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.45 : true))
const unresolved = dedupeItems(
unresolvedRaw,
(item) => item.sourceMessageIds || [],
(item) => createSignature(item.question, item.note)
)
.slice(0, config.maxUnresolved)
.map((item) => ({
...item,
question: truncate(item.question, config.noteLength),
note: truncate(item.note, config.noteLength)
}))
const quotesRaw = sortByScore(report.quotes, (item) =>
Math.max(item.importance || 0.55, item.confidence || 0.55, scoreByCount(item.messages.length, 4))
)
const quotes = dedupeItems(
quotesRaw,
(item) => item.sourceMessageIds || item.messages.map((message) => message.sourceMessageId || ''),
(item) => createSignature(item.note, item.messages.map((message) => message.content).join('|'))
)
.slice(0, config.maxQuotes)
.map((item) => ({
...item,
messages: item.messages.slice(0, config.maxQuoteMessages).map((message) => ({
...message,
content: truncate(message.content, Math.min(config.noteLength, 38))
})),
note: truncate(item.note, config.noteLength)
}))
const resources = report.resources.slice(0, config.maxResources)
const qa = report.qa.slice(0, config.maxQa)
const storylines = report.storylines.slice(0, config.maxStorylines)
const reversals = report.reversals.slice(0, config.maxReversals)
const participantChains = report.participantChains.slice(0, config.maxChains)
const voiceHighlights = report.media.voiceHighlights.slice(0, config.maxVoiceHighlights)
const funBadges = report.media.funBadges.slice(0, config.maxBadges)
const keywords = report.keywords.slice(0, config.maxKeywords)
const conclusionCount =
topics.reduce((count, topic) => count + (topic.conclusions?.length || 0), 0) +
importantMessages.length
const summaryStats = {
messageCount: metadata.messageCount,
activeUsers: metadata.activeUsers,
topicCount: topics.length,
mediaCount: metadata.mediaMessageCount || 0,
imageCount: metadata.imageCount || 0,
voiceCount: metadata.voiceCount || 0,
stickerCount: metadata.stickerCount || 0,
conclusionCount,
todoCount: todos.length,
unresolvedCount: unresolved.length
}
const hero = {
headline:
report.hero?.headline || topics[0]?.title || `${metadata.groupName}${mode === 'compact' ? '速览' : '日报'}`,
summary: truncate(
report.hero?.summary || report.overview || '今天群里有新的讨论进展。',
mode === 'compact' ? 84 : 120
),
keyTakeaway: report.hero?.keyTakeaway ? truncate(report.hero.keyTakeaway, config.noteLength) : undefined,
pendingNote: report.hero?.pendingNote ? truncate(report.hero.pendingNote, config.noteLength) : undefined,
statusLine:
report.hero?.statusLine ||
`今日形成 ${summaryStats.conclusionCount} 个结论 · ${summaryStats.todoCount} 个待办 · ${summaryStats.unresolvedCount} 个问题尚未解决`
}
const sectionMeta: Partial<Record<ReportSectionKey, ReportSectionMeta>> = {
hero: buildSectionMeta(true, 1, 1, 1, 0.95),
topics: buildSectionMeta(config.enabledSections.includes('topics'), topics.length, report.topics.length, 0.98, 0.85),
importantMessages: buildSectionMeta(
config.enabledSections.includes('importantMessages'),
importantMessages.length,
report.importantMessages.length,
0.95,
0.82
),
actions: buildSectionMeta(
config.enabledSections.includes('actions'),
todos.length + unresolved.length,
report.todos.length + report.unresolved.length,
0.97,
0.8
),
moments: buildSectionMeta(config.enabledSections.includes('moments'), quotes.length, report.quotes.length, 0.75, 0.72),
analytics: buildSectionMeta(config.enabledSections.includes('analytics'), 1, 1, 0.8, 0.95),
keywords: buildSectionMeta(config.enabledSections.includes('keywords'), keywords.length, report.keywords.length, 0.68, 0.9),
resources: buildSectionMeta(config.enabledSections.includes('resources'), resources.length, report.resources.length, 0.55, 0.75),
qa: buildSectionMeta(config.enabledSections.includes('qa'), qa.length, report.qa.length, 0.62, 0.78),
storylines: buildSectionMeta(config.enabledSections.includes('storylines'), storylines.length, report.storylines.length, 0.63, 0.74),
reversals: buildSectionMeta(config.enabledSections.includes('reversals'), reversals.length, report.reversals.length, 0.54, 0.72),
gallery: buildSectionMeta(config.enabledSections.includes('gallery'), gallery.length, report.media.gallery.length, 0.6, 0.8),
voices: buildSectionMeta(config.enabledSections.includes('voices'), voiceHighlights.length, report.media.voiceHighlights.length, 0.56, 0.84),
badges: buildSectionMeta(config.enabledSections.includes('badges'), funBadges.length, report.media.funBadges.length, 0.45, 0.65),
chains: buildSectionMeta(config.enabledSections.includes('chains'), participantChains.length, report.participantChains.length, 0.58, 0.71)
}
return {
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,
@@ -335,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)
}
+168 -4
View File
@@ -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<Record<ReportSectionKey, ReportSectionMeta>>
}
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<string, string | undefined>
// === 新增(可选,向后兼容) ===
/** 群昵称 / wxid / md5,服务端用来反推真头像(从 getGroupSnapshot) */
reportMode?: ReportMode
talker?: string
/** 预留,与 /api/v1/chatlog 的 time 参数同格式 */
timeRange?: string
/** 服务端写回,告知 client enrich 失败/部分缺失 */
warnings?: string[]
}