feat: 增加日报模板

This commit is contained in:
Wxw-Gu
2026-08-12 16:10:22 +08:00
parent cf3f115124
commit d439b4b749
36 changed files with 5068 additions and 559 deletions
File diff suppressed because it is too large Load Diff
-32
View File
@@ -464,32 +464,6 @@
font-style: normal; font-style: normal;
color: #98a2b3; 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 { .badge-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -644,7 +618,6 @@
padding: 11px; padding: 11px;
} }
.compact .important-card, .compact .important-card,
.compact .gallery-card,
.compact .chat-block { .compact .chat-block {
padding: 10px; padding: 10px;
} }
@@ -801,11 +774,6 @@
{{VISION_CARDS}} {{VISION_CARDS}}
</section> </section>
<section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div>
{{GALLERY_CARDS}}
{{GALLERY_MORE_NOTE}}
</section>
<section class="section {{VOICE_EMPTY_CLASS}}"> <section class="section {{VOICE_EMPTY_CLASS}}">
<div class="section-title">语音之最</div> <div class="section-title">语音之最</div>
-32
View File
@@ -465,32 +465,6 @@
font-style: normal; font-style: normal;
color: #98a2b3; 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 { .badge-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -645,7 +619,6 @@
padding: 11px; padding: 11px;
} }
.compact .important-card, .compact .important-card,
.compact .gallery-card,
.compact .chat-block { .compact .chat-block {
padding: 10px; padding: 10px;
} }
@@ -801,11 +774,6 @@
{{VISION_CARDS}} {{VISION_CARDS}}
</section> </section>
<section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div>
{{GALLERY_CARDS}}
{{GALLERY_MORE_NOTE}}
</section>
<section class="section {{VOICE_EMPTY_CLASS}}"> <section class="section {{VOICE_EMPTY_CLASS}}">
<div class="section-title">语音之最</div> <div class="section-title">语音之最</div>
@@ -0,0 +1,244 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const fs = require('fs')
const path = require('path')
const { JSDOM } = require('jsdom')
const templates = [
{
id: 'mobile-feed',
className: 'template-mobile-feed',
label: 'Mobile 01',
name: '微信信息流',
width: 390
},
{
id: 'mobile-magazine',
className: 'template-mobile-magazine',
label: 'Mobile 02',
name: 'AI Magazine',
width: 390
},
{
id: 'mobile-dashboard',
className: 'template-mobile-dashboard',
label: 'Mobile 03',
name: 'AI Command Center',
width: 390
},
{
id: 'desktop-workspace',
className: 'template-desktop-workspace',
label: 'Desktop 01',
name: '三栏 AI 工作台',
width: 1440
},
{
id: 'desktop-editorial',
className: 'template-desktop-editorial',
label: 'Desktop 02',
name: 'Editorial 科技日报',
width: 1440
}
]
const sourcePath = path.resolve(process.argv[2] || '')
const outputDir = path.resolve(
process.argv[3] || path.join(process.cwd(), '.codex', 'report-template-preview')
)
const templatePath = path.join(process.cwd(), 'resources', 'daily_report_templates.html')
if (!sourcePath || !fs.existsSync(sourcePath)) {
console.error(
'用法: node scripts/generate-report-template-preview.cjs <现有日报.html> [输出目录]'
)
process.exit(1)
}
if (!fs.existsSync(templatePath)) {
console.error(`模板资源不存在: ${templatePath}`)
process.exit(1)
}
const document = new JSDOM(fs.readFileSync(sourcePath, 'utf8')).window.document
const templateHtml = fs.readFileSync(templatePath, 'utf8')
const one = (selector) => document.querySelector(selector)
const html = (selector) => one(selector)?.innerHTML || ''
const childrenAfterTitle = (selector) => {
const section = one(selector)
if (!section) return ''
return Array.from(section.children)
.filter((child) => !child.classList.contains('section-title'))
.map((child) => child.outerHTML)
.join('')
}
const sectionClass = (selector) => {
const section = one(selector)
return !section || section.classList.contains('empty-section') ? 'empty-section' : ''
}
const statValues = Array.from(document.querySelectorAll('.stat')).map((node) => {
const strong = node.querySelector('strong')?.textContent?.trim()
if (strong) return strong
return node.textContent?.trim().match(/[\d.]+\s*h?/i)?.[0] || ''
})
const title = one('.hero h1')?.textContent?.trim() || document.title
const subItems = Array.from(document.querySelectorAll('.sub > *'))
const dateTimeRange = subItems[0]?.textContent?.trim() || ''
const reportDate = dateTimeRange.match(/\d{4}-\d{2}-\d{2}/)?.[0] || ''
const recordNote = one('.record-note')?.textContent?.trim() || ''
const overview = one('.overview')?.textContent?.trim() || ''
const footer = one('.footer')?.textContent?.trim().replace(/\s+/g, ' ') || ''
const generatedAt = footer.match(/生成时间[:]\s*([^基]+?)(?:基于|$)/)?.[1]?.trim() || ''
const activityLine = Array.from(document.querySelectorAll('.analytics > .card')).find((node) =>
node.textContent?.includes('活跃时间线')
)
const values = {
REPORT_TITLE: title,
REPORT_DATE: reportDate,
DATE_RANGE: '今天',
TIME_SPAN: statValues[2] || dateTimeRange,
HERO_SUMMARY: overview,
HERO_TAKEAWAY: '',
HERO_PENDING: '',
HERO_STATUS_LINE: '',
HERO_AVATARS: html('.avatar-grid'),
HERO_AVATAR_CLASS: sectionClass('.avatar-grid'),
MESSAGE_COUNT: statValues[0] || '',
ACTIVE_USERS: statValues[1] || '',
TOPIC_COUNT: statValues[3] || '',
RECORD_NOTE: recordNote,
GENERATED_AT: generatedAt,
FOOTER_NOTE: footer,
TOPIC_CARDS: childrenAfterTitle('.topics'),
IMPORTANT_MESSAGES: childrenAfterTitle('.messages'),
QUOTE_BLOCKS: childrenAfterTitle('.quotes'),
QA_CARDS: childrenAfterTitle('.qa'),
HEAT_BARS: Array.from(document.querySelectorAll('.analytics > .heat-row'))
.map((node) => node.outerHTML)
.join(''),
RANK_ITEMS: Array.from(document.querySelectorAll('.analytics .rank'))
.map((node) => node.outerHTML)
.join(''),
ACTIVITY_TIMELINE: activityLine?.textContent?.trim() || '',
CLOUD_TAGS: html('.cloud-tags'),
RESOURCE_ITEMS: childrenAfterTitle('.resources'),
TODO_CARDS: '',
UNRESOLVED_CARDS: '',
STORYLINE_CARDS: '',
REVERSAL_CARDS: '',
CHAIN_CARDS: '',
VISION_TITLE: 'AI 识别的图片精选',
VISION_CARDS: childrenAfterTitle('.vision'),
VOICE_CARDS: '',
VOICE_RANK_CARDS: '',
BADGE_CARDS: '',
KEYWORDS_EMPTY_CLASS: sectionClass('.cloud'),
ANALYTICS_EMPTY_CLASS: sectionClass('.analytics'),
MESSAGES_EMPTY_CLASS: sectionClass('.messages'),
TOPICS_EMPTY_CLASS: sectionClass('.topics'),
QUOTES_EMPTY_CLASS: sectionClass('.quotes'),
RESOURCES_EMPTY_CLASS: sectionClass('.resources'),
QA_EMPTY_CLASS: sectionClass('.qa'),
ACTIONS_EMPTY_CLASS: 'empty-section',
STORYLINES_EMPTY_CLASS: 'empty-section',
REVERSALS_EMPTY_CLASS: 'empty-section',
CHAINS_EMPTY_CLASS: 'empty-section',
VISION_EMPTY_CLASS: sectionClass('.vision'),
VOICE_EMPTY_CLASS: 'empty-section',
VOICE_RANK_EMPTY_CLASS: 'empty-section',
BADGES_EMPTY_CLASS: 'empty-section',
HERO_TAKEAWAY_EMPTY_CLASS: 'empty-section',
HERO_PENDING_EMPTY_CLASS: 'empty-section',
HERO_STATUS_EMPTY_CLASS: 'empty-section'
}
const escapeHtml = (value) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
const render = (template) => {
let result = templateHtml
const replacements = {
...values,
TEMPLATE_CLASS: template.className,
TEMPLATE_LABEL: template.label,
TEMPLATE_NAME: template.name
}
const htmlKeys = new Set([
'HERO_AVATARS',
'TOPIC_CARDS',
'IMPORTANT_MESSAGES',
'QUOTE_BLOCKS',
'QA_CARDS',
'HEAT_BARS',
'RANK_ITEMS',
'CLOUD_TAGS',
'RESOURCE_ITEMS',
'TODO_CARDS',
'UNRESOLVED_CARDS',
'STORYLINE_CARDS',
'REVERSAL_CARDS',
'CHAIN_CARDS',
'VISION_CARDS',
'VOICE_CARDS',
'VOICE_RANK_CARDS',
'BADGE_CARDS'
])
for (const [key, value] of Object.entries(replacements)) {
const safeValue = htmlKeys.has(key) ? String(value || '') : escapeHtml(value)
result = result.replaceAll(`{{${key}}}`, safeValue)
}
return result.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
}
fs.mkdirSync(outputDir, { recursive: true })
for (const template of templates) {
fs.writeFileSync(path.join(outputDir, `${template.id}.html`), render(template), 'utf8')
}
const reportName = title.replace(/日报$/, '')
const buttons = templates
.map(
(template, index) => `
<button class="${index === 0 ? 'active' : ''}" data-src="${template.id}.html" data-width="${template.width}">
<span>${template.label}</span><b>${template.name}</b>
</button>`
)
.join('')
const indexHtml = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(reportName)} · 五套日报模板预览</title>
<style>
*{box-sizing:border-box} body{margin:0;background:#e9eeeb;color:#17211d;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}
header{position:sticky;top:0;z-index:2;padding:18px 24px 14px;background:rgba(255,255,255,.95);border-bottom:1px solid #d7e0da;backdrop-filter:blur(14px)}
h1{margin:0;font-size:20px} p{margin:5px 0 0;color:#68736c;font-size:12px}.toolbar{display:flex;gap:8px;overflow-x:auto;margin-top:14px;padding-bottom:2px}
button{display:grid;flex:0 0 auto;gap:2px;min-width:142px;padding:9px 12px;border:1px solid #d8e1db;border-radius:9px;background:#fff;color:#24332b;text-align:left;cursor:pointer}
button span{color:#708078;font-size:9px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}button b{font-size:12px}button.active{border-color:#16835b;background:#eaf6ef;color:#0d6744}
.stage{display:flex;justify-content:center;min-height:calc(100vh - 132px);padding:24px;overflow:auto}.frame-shell{width:390px;max-width:100%;overflow:hidden;border:1px solid #cbd6cf;border-radius:14px;background:white;box-shadow:0 18px 48px rgba(30,55,43,.15);transition:width .2s ease}
iframe{display:block;width:100%;height:calc(100vh - 180px);min-height:680px;border:0;background:white}
@media(max-width:640px){header{padding:14px 12px 12px}.stage{padding:12px}.frame-shell{border-radius:10px}button{min-width:132px}}
</style>
</head>
<body>
<header><h1>${escapeHtml(reportName)} · 五套日报模板</h1><p>同一份 2026-08-11 真实日报数据,可直接切换比较布局、排版和信息密度。</p><div class="toolbar">${buttons}</div></header>
<main class="stage"><div class="frame-shell"><iframe title="日报模板预览" src="mobile-feed.html"></iframe></div></main>
<script>
const frame=document.querySelector('iframe');const shell=document.querySelector('.frame-shell');
document.querySelectorAll('button').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('button').forEach(item=>item.classList.remove('active'));button.classList.add('active');frame.src=button.dataset.src;shell.style.width=button.dataset.width+'px'}));
</script>
</body>
</html>`
fs.writeFileSync(path.join(outputDir, 'index.html'), indexHtml, 'utf8')
console.log(path.join(outputDir, 'index.html'))
+1 -23
View File
@@ -182,7 +182,6 @@ fullRequest.report.sectionMeta = {
qa: { enabled: true, importance: 0.62, confidence: 0.8, 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 }, 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 }, 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 }, 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 }, 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 } chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
@@ -218,24 +217,7 @@ fullRequest.report.reversals = [
{ topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' } { topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
] ]
fullRequest.report.media = { fullRequest.report.media = {
gallery: [ gallery: [],
{
sender: '阿宇',
time: '09:52',
imageUrl: sampleImage,
note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
stats: '12 条后续消息 · 6 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
},
{
sender: '佩佩',
time: '17:14',
imageUrl: sampleImage,
note: '第二张图带起一轮轻松但有效的快速确认。',
stats: '5 条后续消息 · 3 人接话',
inferenceLabel: '基于图片后的聊天上下文推断'
}
],
voiceHighlights: [ voiceHighlights: [
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' }, { title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' } { title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
@@ -281,7 +263,6 @@ async function renderRequest(request, targetBase) {
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 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 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 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 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 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 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('')
@@ -343,9 +324,6 @@ async function renderRequest(request, targetBase) {
REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section', REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
REVERSAL_CARDS: reversalCards, REVERSAL_CARDS: reversalCards,
REVERSALS_MORE_NOTE: '', 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_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
VOICE_CARDS: voiceCards, VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: '', VOICE_MORE_NOTE: '',
+296 -30
View File
@@ -6,21 +6,23 @@ import {
GroupReportExportRequest, GroupReportExportRequest,
GroupReportExportResult, GroupReportExportResult,
GroupReportMetadata, GroupReportMetadata,
GroupReportRenderSnapshot,
GroupReportRenderSnapshotExportRequest,
ReportHeat, ReportHeat,
ReportSectionMeta, ReportSectionMeta,
selectHeroParticipantNames selectHeroParticipantNames
} from '../shared/group-report' } from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service' import { resolveMd5, getGroupSnapshot } from './services/chat-service'
import { imageInsightService } from './services/image-insight-service' import { imageInsightService } from './services/image-insight-service'
import { getReportTemplate } from '../shared/report-templates'
const TEMPLATE_FILES: Record<string, string> = { const LEGACY_TEMPLATE_FILES: Record<string, string> = {
v1: 'mobile_daily_report_v1.html', v1: 'mobile_daily_report_v1.html',
v2: 'mobile_daily_report_v2.html' v2: 'mobile_daily_report_v2.html'
} }
const DEFAULT_TEMPLATE = TEMPLATE_FILES.v1
const templatePath = (templateId?: string): string => { const templatePath = (templateId?: string): string => {
const name = TEMPLATE_FILES[templateId || ''] || DEFAULT_TEMPLATE const name = LEGACY_TEMPLATE_FILES[templateId || ''] || getReportTemplate(templateId).resourceFile
const candidates = [ const candidates = [
path.join(process.resourcesPath, 'resources', name), path.join(process.resourcesPath, 'resources', name),
path.join(app.getAppPath(), 'resources', name), path.join(app.getAppPath(), 'resources', name),
@@ -168,6 +170,7 @@ const overflowNote = (
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => { const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
const { report, metadata } = request const { report, metadata } = request
const template = getReportTemplate(request.templateId)
const avatarNames = new Set<string>(metadata.heroParticipants) const avatarNames = new Set<string>(metadata.heroParticipants)
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name))) report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
report.importantMessages.forEach((message) => avatarNames.add(message.sender)) report.importantMessages.forEach((message) => avatarNames.add(message.sender))
@@ -175,7 +178,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
quote.messages.forEach((message) => avatarNames.add(message.sender)) quote.messages.forEach((message) => avatarNames.add(message.sender))
) )
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name)) report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender)) report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner)) report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
@@ -341,20 +343,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
) )
.join('') .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('')
// AI 图片理解结果板块(ImageInsight) // AI 图片理解结果板块(ImageInsight)
// 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文 // 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
const visionCards = (report.media?.visionGallery || []) const visionCards = (report.media?.visionGallery || [])
@@ -411,11 +399,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
.join('') .join('')
// v1 模板使用的水平条形热度图,渲染 top speakers 排行 // v1 模板使用的水平条形热度图,渲染 top speakers 排行
const heatBarsHtml = report.analytics.topSpeakers const heatSpeakers = report.analytics.topSpeakers.slice(0, 8)
.slice(0, 8) const maxSpeakerCount = Math.max(1, ...heatSpeakers.map((speaker) => Math.max(0, speaker.count)))
const heatBarsHtml = heatSpeakers
.map((speaker) => { .map((speaker) => {
const count = Math.max(0, speaker.count) const count = Math.max(0, speaker.count)
const width = Math.min(100, count * 12) const width = Math.max(4, Math.round((count / maxSpeakerCount) * 100))
return `<div class="heat-row"> return `<div class="heat-row">
<span class="heat-name">${escapeHtml(speaker.name)}</span> <span class="heat-name">${escapeHtml(speaker.name)}</span>
<span class="heat-bar"><i style="width:${width}%"></i></span> <span class="heat-bar"><i style="width:${width}%"></i></span>
@@ -454,7 +443,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
let html = await fs.readFile(templatePath(request.templateId), 'utf8') let html = await fs.readFile(templatePath(request.templateId), 'utf8')
const values: Record<string, string> = { const values: Record<string, string> = {
TEMPLATE_CLASS: template.cssClass,
TEMPLATE_LABEL: escapeHtml(template.label),
TEMPLATE_NAME: escapeHtml(template.name),
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`), REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
REPORT_DATE: escapeHtml(metadata.reportDate),
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact', REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
GROUP_NAME: escapeHtml(metadata.groupName), GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange), DATE_RANGE: escapeHtml(metadata.dateRange),
@@ -525,9 +518,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
), ),
VISION_CARDS: visionCards, VISION_CARDS: visionCards,
VISION_TITLE: '📸 AI 识别的图片精选', VISION_TITLE: '📸 AI 识别的图片精选',
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_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
VOICE_CARDS: voiceCards, VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: overflowNote(request, 'voices'), VOICE_MORE_NOTE: overflowNote(request, 'voices'),
@@ -560,11 +550,242 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
return html return html
} }
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => { const renderReportSnapshotHtml = async (
request: GroupReportRenderSnapshotExportRequest
): Promise<string> => {
const template = getReportTemplate(request.templateId)
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
const values = {
...request.snapshot.values,
TEMPLATE_CLASS: template.cssClass,
TEMPLATE_LABEL: escapeHtml(template.label),
TEMPLATE_NAME: escapeHtml(template.name),
REPORT_TITLE:
request.snapshot.values.REPORT_TITLE || escapeHtml(`${request.snapshot.groupName}日报`),
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
}
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
return html.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
}
export const extractGroupReportRenderSnapshot = async (
htmlPath: string,
fallback: {
groupName: string
reportDate: string
dateRange: string
messageCount: number
generatedAt: string
}
): Promise<GroupReportRenderSnapshot> => {
let reportWindow: BrowserWindow | null = null
try {
reportWindow = new BrowserWindow({
show: false,
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
})
await reportWindow.loadFile(htmlPath)
const extracted = (await reportWindow.webContents.executeJavaScript(`(() => {
const one = (selector) => document.querySelector(selector)
const text = (...selectors) => {
for (const selector of selectors) {
const value = one(selector)?.textContent?.trim()
if (value) return value
}
return ''
}
const html = (...selectors) => {
for (const selector of selectors) {
const value = one(selector)?.innerHTML
if (value?.trim()) return value
}
return ''
}
const sectionChildren = (selector) => {
const section = one(selector)
if (!section) return ''
return Array.from(section.children)
.filter((child) => !child.classList.contains('section-title'))
.map((child) => child.outerHTML)
.join('')
}
const sectionClass = (...selectors) => {
for (const selector of selectors) {
const section = one(selector)
if (!section) continue
return section.classList.contains('empty-section') || !sectionChildren(selector).trim()
? 'empty-section'
: ''
}
return 'empty-section'
}
const statValues = Array.from(document.querySelectorAll('.hero .stat b, .report-stats .stat-block strong'))
.map((node) => node.textContent?.trim() || '')
const activity = Array.from(document.querySelectorAll('.analytics > .card, .section-analytics-heat .card'))
.find((node) => node.textContent?.includes('活跃时间线'))
?.textContent?.replace(/^.*?活跃时间线[:]?/, '')
.trim() || ''
const legacyRanks = Array.from(document.querySelectorAll('.analytics .rank'))
.map((node) => node.outerHTML)
.join('')
const legacyHeat = Array.from(document.querySelectorAll('.analytics > .heat-row'))
.map((node) => node.outerHTML)
.join('')
const footerText = text('.footer', '.report-footer')
.replaceAll('\\n', ' ')
.replaceAll('\\r', ' ')
.replaceAll('\\t', ' ')
return {
reportTitle: text('.hero h1', '.report-masthead h1', 'title'),
reportDate: text('.report-date strong'),
overview: text('.overview', '.report-lede'),
recordNote: text('.record-note'),
heroAvatars: html('.avatar-grid', '.report-hero-avatars'),
messageCount: statValues[0] || '',
activeUsers: statValues[1] || '',
timeSpan: statValues[2] || '',
topicCount: statValues[3] || '',
topicCards: sectionChildren('.topics') || html('.section-topics .topics-grid'),
importantMessages: sectionChildren('.messages') || html('.section-messages .section-body'),
quoteBlocks: sectionChildren('.quotes') || html('.section-quotes .section-body'),
qaCards: sectionChildren('.qa') || html('.section-qa .section-body'),
resourceItems: sectionChildren('.resources') || html('.section-resources .section-body'),
visionCards: sectionChildren('.vision') || html('.section-vision .vision-grid'),
rankItems: legacyRanks || html('.section-analytics-rank .rank-list'),
heatBars: legacyHeat || html('.section-analytics-heat .section-body'),
activityTimeline: activity,
cloudTags: html('.cloud-tags', '.section-keywords .cloud-tags'),
footerText,
classes: {
topics: sectionClass('.topics', '.section-topics'),
messages: sectionClass('.messages', '.section-messages'),
quotes: sectionClass('.quotes', '.section-quotes'),
qa: sectionClass('.qa', '.section-qa'),
resources: sectionClass('.resources', '.section-resources'),
vision: sectionClass('.vision', '.section-vision'),
keywords: sectionClass('.cloud', '.section-keywords')
}
}
})()`)) as {
reportTitle: string
reportDate: string
overview: string
recordNote: string
heroAvatars: string
messageCount: string
activeUsers: string
timeSpan: string
topicCount: string
topicCards: string
importantMessages: string
quoteBlocks: string
qaCards: string
resourceItems: string
visionCards: string
rankItems: string
heatBars: string
activityTimeline: string
cloudTags: string
footerText: string
classes: Record<string, string>
}
if (!extracted.reportTitle || !extracted.topicCards) {
throw new Error('旧日报 HTML 缺少可迁移的标题或主题内容')
}
const values: Record<string, string> = {
REPORT_TITLE: escapeHtml(extracted.reportTitle),
REPORT_DATE: escapeHtml(extracted.reportDate || fallback.reportDate),
DATE_RANGE: escapeHtml(fallback.dateRange),
TIME_SPAN: escapeHtml(extracted.timeSpan),
HERO_SUMMARY: escapeHtml(extracted.overview),
HERO_TAKEAWAY: '',
HERO_PENDING: '',
HERO_STATUS_LINE: '',
HERO_TAKEAWAY_EMPTY_CLASS: 'empty-section',
HERO_PENDING_EMPTY_CLASS: 'empty-section',
HERO_STATUS_EMPTY_CLASS: 'empty-section',
HERO_AVATARS: extracted.heroAvatars,
HERO_AVATAR_CLASS: extracted.heroAvatars ? '' : 'empty-section',
MESSAGE_COUNT: escapeHtml(extracted.messageCount || String(fallback.messageCount)),
ACTIVE_USERS: escapeHtml(extracted.activeUsers),
TOPIC_COUNT: escapeHtml(extracted.topicCount),
RECORD_NOTE: escapeHtml(extracted.recordNote),
GENERATED_AT: escapeHtml(fallback.generatedAt),
FOOTER_NOTE: escapeHtml(extracted.footerText),
TOPIC_CARDS: extracted.topicCards,
IMPORTANT_MESSAGES: extracted.importantMessages,
QUOTE_BLOCKS: extracted.quoteBlocks,
QA_CARDS: extracted.qaCards,
RESOURCE_ITEMS: extracted.resourceItems,
VISION_TITLE: '📸 AI 识别的图片精选',
VISION_CARDS: extracted.visionCards,
RANK_ITEMS: extracted.rankItems,
HEAT_BARS: extracted.heatBars,
ACTIVITY_TIMELINE: escapeHtml(extracted.activityTimeline),
CLOUD_TAGS: extracted.cloudTags,
TOPICS_EMPTY_CLASS: extracted.classes.topics,
MESSAGES_EMPTY_CLASS: extracted.classes.messages,
QUOTES_EMPTY_CLASS: extracted.classes.quotes,
QA_EMPTY_CLASS: extracted.classes.qa,
RESOURCES_EMPTY_CLASS: extracted.classes.resources,
VISION_EMPTY_CLASS: extracted.classes.vision,
KEYWORDS_EMPTY_CLASS: extracted.classes.keywords,
ANALYTICS_EMPTY_CLASS: extracted.heatBars || extracted.rankItems ? '' : 'empty-section',
ACTIONS_EMPTY_CLASS: 'empty-section',
STORYLINES_EMPTY_CLASS: 'empty-section',
REVERSALS_EMPTY_CLASS: 'empty-section',
CHAINS_EMPTY_CLASS: 'empty-section',
VOICE_EMPTY_CLASS: 'empty-section',
VOICE_RANK_EMPTY_CLASS: 'empty-section',
BADGES_EMPTY_CLASS: 'empty-section',
TODO_CARDS: '',
UNRESOLVED_CARDS: '',
STORYLINE_CARDS: '',
REVERSAL_CARDS: '',
CHAIN_CARDS: '',
VOICE_CARDS: '',
VOICE_RANK_CARDS: '',
BADGE_CARDS: '',
TOPICS_MORE_NOTE: '',
MESSAGES_MORE_NOTE: '',
QUOTES_MORE_NOTE: '',
QA_MORE_NOTE: '',
RESOURCES_MORE_NOTE: '',
ACTIONS_MORE_NOTE: '',
STORYLINES_MORE_NOTE: '',
REVERSALS_MORE_NOTE: '',
CHAINS_MORE_NOTE: '',
VOICE_MORE_NOTE: '',
BADGES_MORE_NOTE: '',
KEYWORDS_MORE_NOTE: ''
}
return {
groupName: fallback.groupName,
reportDate: extracted.reportDate || fallback.reportDate,
values
}
} finally {
if (reportWindow && !reportWindow.isDestroyed()) reportWindow.destroy()
}
}
const captureFullPage = async (
htmlPath: string,
pngPath: string,
templateId?: string
): Promise<string> => {
const template = getReportTemplate(templateId)
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
console.log(`[GroupReport] capture begin html=${htmlPath}`) console.log(`[GroupReport] capture begin html=${htmlPath}`)
const reportWindow = new BrowserWindow({ const reportWindow = new BrowserWindow({
show: false, show: false,
width: 430, width: captureWidth,
height: 800, height: 800,
frame: false, frame: false,
backgroundColor: '#f3f5f7', backgroundColor: '#f3f5f7',
@@ -583,10 +804,10 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise<strin
])`) ])`)
console.log('[GroupReport] capture assets ready') console.log('[GroupReport] capture assets ready')
const metrics = (await reportWindow.webContents.executeJavaScript(`({ const metrics = (await reportWindow.webContents.executeJavaScript(`({
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 430)), width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, ${captureWidth})),
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800)) height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
})`)) as { width: number; height: number } })`)) as { width: number; height: number }
const width = Math.max(430, Math.min(1200, Math.ceil(metrics.width))) const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width)))
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height))) const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
reportWindow.setContentSize(width, height) reportWindow.setContentSize(width, height)
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
@@ -611,7 +832,12 @@ export const exportGroupReport = async (
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录') const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir) await fs.ensureDir(outputDir)
const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2' const templateLabel =
request.templateId === 'v1'
? '经典版'
: request.templateId === 'v2'
? '丰富版'
: getReportTemplate(request.templateId).fileLabel
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}` const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}`
const htmlPath = path.join(outputDir, `${baseName}.html`) const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`) const pngPath = path.join(outputDir, `${baseName}.png`)
@@ -620,7 +846,7 @@ export const exportGroupReport = async (
await fs.writeFile(htmlPath, html, 'utf8') await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date() const htmlEndedAt = new Date()
const pngStartedAt = new Date() const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(htmlPath, pngPath) const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
const pngEndedAt = new Date() const pngEndedAt = new Date()
return { return {
success: true, success: true,
@@ -646,3 +872,43 @@ export const exportGroupReport = async (
return { success: false, error: error instanceof Error ? error.message : String(error) } return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
} }
export const exportGroupReportSnapshot = async (
request: GroupReportRenderSnapshotExportRequest
): Promise<GroupReportExportResult> => {
try {
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const templateLabel = getReportTemplate(request.templateId).fileLabel
const baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const htmlStartedAt = new Date()
const html = await renderReportSnapshotHtml(request)
await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
const pngEndedAt = new Date()
return {
success: true,
htmlPath,
pngPath,
imageDataUrl,
exportTimings: {
html: {
startedAt: htmlStartedAt.toISOString(),
endedAt: htmlEndedAt.toISOString(),
duration: htmlEndedAt.getTime() - htmlStartedAt.getTime()
},
png: {
startedAt: pngStartedAt.toISOString(),
endedAt: pngEndedAt.toISOString(),
duration: pngEndedAt.getTime() - pngStartedAt.getTime()
}
}
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
+34 -4
View File
@@ -32,14 +32,27 @@ import {
inspectImageDecoderStatus, inspectImageDecoderStatus,
type DecodedImage type DecodedImage
} from './image-decrypt-service' } from './image-decrypt-service'
import { exportGroupReport } from './group-report-service' import {
exportGroupReport,
exportGroupReportSnapshot,
extractGroupReportRenderSnapshot
} from './group-report-service'
import { import {
deleteGeneratedReport, deleteGeneratedReport,
listGeneratedReports, listGeneratedReports,
saveGeneratedReport prepareGeneratedReportTemplateSwitch,
saveGeneratedReport,
updateGeneratedReportTemplate
} from './report-history-service' } from './report-history-service'
import type { GroupReportExportRequest } from '../shared/group-report' import type {
import type { SaveGeneratedReportRequest } from '../shared/report-history' GroupReportExportRequest,
GroupReportRenderSnapshotExportRequest
} from '../shared/group-report'
import type {
SaveGeneratedReportRequest,
PrepareGeneratedReportTemplateSwitchRequest,
UpdateGeneratedReportTemplateRequest
} from '../shared/report-history'
import type { import type {
AIChatRequestOptions, AIChatRequestOptions,
AiSearchExternalAuthorizationRequest, AiSearchExternalAuthorizationRequest,
@@ -1132,6 +1145,10 @@ app.whenReady().then(async () => {
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => { ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
return exportGroupReport(request) return exportGroupReport(request)
}) })
ipcMain.handle(
'report:exportSnapshot',
async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request)
)
ipcMain.handle('export:start', async (event, request: ExportRequest) => { ipcMain.handle('export:start', async (event, request: ExportRequest) => {
const window = BrowserWindow.fromWebContents(event.sender) const window = BrowserWindow.fromWebContents(event.sender)
@@ -1158,6 +1175,19 @@ app.whenReady().then(async () => {
return saveGeneratedReport(request) return saveGeneratedReport(request)
}) })
ipcMain.handle(
'report:updateGeneratedTemplate',
async (_, request: UpdateGeneratedReportTemplateRequest) => {
return updateGeneratedReportTemplate(request)
}
)
ipcMain.handle(
'report:prepareTemplateSwitch',
async (_, request: PrepareGeneratedReportTemplateSwitchRequest) =>
prepareGeneratedReportTemplateSwitch(request.reportId, extractGroupReportRenderSnapshot)
)
ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => { ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => {
return deleteGeneratedReport(reportId) return deleteGeneratedReport(reportId)
}) })
+126 -2
View File
@@ -7,8 +7,11 @@ import type {
ReportAssetStatus, ReportAssetStatus,
ReportHistoryResult, ReportHistoryResult,
SaveGeneratedReportRequest, SaveGeneratedReportRequest,
SaveGeneratedReportResult SaveGeneratedReportResult,
UpdateGeneratedReportTemplateRequest,
UpdateGeneratedReportTemplateResult
} from '../shared/report-history' } from '../shared/report-history'
import type { GroupReportRenderSnapshot } from '../shared/group-report'
const REPORTS_DIR = 'reports' const REPORTS_DIR = 'reports'
@@ -184,7 +187,10 @@ export async function saveGeneratedReport(
html: await readFileSize(savedHtmlPath), html: await readFileSize(savedHtmlPath),
png: await readFileSize(savedPngPath) png: await readFileSize(savedPngPath)
}, },
generationLogs: request.generationLogs generationLogs: request.generationLogs,
reportSnapshot: request.reportSnapshot,
reportMetadata: request.reportMetadata,
templateId: request.templateId
} }
await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8') await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8')
@@ -200,6 +206,124 @@ export async function saveGeneratedReport(
} }
} }
export async function updateGeneratedReportTemplate(
request: UpdateGeneratedReportTemplateRequest
): Promise<UpdateGeneratedReportTemplateResult> {
try {
const jsonFiles = await walkJsonFiles(getReportsRoot())
for (const jsonPath of jsonFiles) {
try {
const content = await fs.readFile(jsonPath, 'utf8')
const record = JSON.parse(content) as GeneratedReportRecord
if (record.id !== request.reportId) continue
if ((!record.reportSnapshot || !record.reportMetadata) && !record.reportRenderSnapshot) {
return { success: false, error: '旧报告未保存结构化数据,无法无损切换模板' }
}
const directory = path.dirname(jsonPath)
const baseName = path.basename(jsonPath, '.json')
const htmlPath = record.htmlPath || path.join(directory, `${baseName}.html`)
const pngPath = record.pngPath || path.join(directory, `${baseName}.png`)
const imageBuffer = request.generatedImage ? parseDataUrl(request.generatedImage) : null
const hasPngFile = Boolean(request.pngPath && (await exists(request.pngPath)))
if (!request.htmlPath || !(await exists(request.htmlPath))) {
return { success: false, error: '新模板 HTML 文件不存在' }
}
if (!imageBuffer && !hasPngFile) {
return { success: false, error: '新模板 PNG 文件不存在' }
}
await fs.copyFile(request.htmlPath, htmlPath)
if (imageBuffer) {
await fs.writeFile(pngPath, imageBuffer)
} else if (request.pngPath) {
await fs.copyFile(request.pngPath, pngPath)
}
const updated: GeneratedReportRecord = {
...record,
htmlPath,
pngPath,
jsonPath,
htmlStatus: 'ready',
pngStatus: 'ready',
imageSize: await readPngSize(pngPath),
fileSize: {
html: await readFileSize(htmlPath),
png: await readFileSize(pngPath)
},
templateId: request.templateId
}
delete updated.generatedImage
await fs.writeFile(jsonPath, JSON.stringify(updated, null, 2), 'utf8')
return {
success: true,
record: {
...updated,
generatedImage: await readPngAsDataUrl(pngPath)
}
}
} catch (error) {
console.warn(
`[ReportHistory] skip invalid report record while switching template: ${jsonPath}`,
error
)
}
}
return { success: false, error: '未找到要切换模板的日报记录' }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function prepareGeneratedReportTemplateSwitch(
reportId: string,
extractSnapshot: (
htmlPath: string,
fallback: {
groupName: string
reportDate: string
dateRange: string
messageCount: number
generatedAt: string
}
) => Promise<GroupReportRenderSnapshot>
): Promise<import('../shared/report-history').PrepareGeneratedReportTemplateSwitchResult> {
try {
const jsonFiles = await walkJsonFiles(getReportsRoot())
for (const jsonPath of jsonFiles) {
try {
const content = await fs.readFile(jsonPath, 'utf8')
const record = JSON.parse(content) as GeneratedReportRecord
if (record.id !== reportId) continue
if (record.reportRenderSnapshot)
return { success: true, snapshot: record.reportRenderSnapshot }
if (!record.htmlPath || !(await exists(record.htmlPath))) {
return { success: false, error: '当前日报缺少 HTML 文件,无法迁移旧模板数据' }
}
const snapshot = await extractSnapshot(record.htmlPath, {
groupName: record.contactName,
reportDate: record.reportDate,
dateRange: record.dateRange,
messageCount: record.messageCount,
generatedAt: record.generatedAt
})
const updated = { ...record, reportRenderSnapshot: snapshot }
await fs.writeFile(jsonPath, JSON.stringify(updated, null, 2), 'utf8')
return { success: true, snapshot }
} catch (error) {
console.warn(
`[ReportHistory] skip invalid report record while preparing template switch: ${jsonPath}`,
error
)
}
}
return { success: false, error: '未找到要切换模板的日报记录' }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function deleteGeneratedReport( export async function deleteGeneratedReport(
reportId: string reportId: string
): Promise<DeleteGeneratedReportResult> { ): Promise<DeleteGeneratedReportResult> {
+16 -3
View File
@@ -5,7 +5,7 @@
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果) // 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
// 2. 同图(imageHash)走缓存,绝不重复调 AI // 2. 同图(imageHash)走缓存,绝不重复调 AI
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报) // 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
// 4. 第一阶段:Top 3 热点图 + 缓存命中即返回,未命中并发调 AI // 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI
import crypto from 'crypto' import crypto from 'crypto'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
@@ -22,6 +22,10 @@ import type {
ImageCandidateQuery, ImageCandidateQuery,
ImageInsight ImageInsight
} from '../../shared/image-insight' } from '../../shared/image-insight'
import {
calculateImageHeatScore,
isHotImageCandidate
} from '../../shared/image-insight'
/** /**
* ( renderer messages main) * ( renderer messages main)
@@ -234,7 +238,7 @@ class ImageInsightService {
query: ImageCandidateQuery, query: ImageCandidateQuery,
inputs: ImageCandidateInput[] = [] inputs: ImageCandidateInput[] = []
): Promise<ImageCandidate[]> { ): Promise<ImageCandidate[]> {
const limit = query.limit ?? 3 const limit = Math.min(3, Math.max(0, query.limit ?? 3))
const candidates: ImageCandidate[] = [] const candidates: ImageCandidate[] = []
console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length) console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length)
for (const input of inputs) { for (const input of inputs) {
@@ -248,7 +252,16 @@ class ImageInsightService {
) )
continue continue
} }
const heatScore = input.responseCount * 3 + input.interactionCount * 2 + 1 if (!isHotImageCandidate(input)) {
console.log(
'[ImageInsightService] skip %s: not hot (responses=%d interactions=%d)',
input.messageId,
input.responseCount,
input.interactionCount
)
continue
}
const heatScore = calculateImageHeatScore(input)
const candidate: ImageCandidate = { const candidate: ImageCandidate = {
messageId: input.messageId, messageId: input.messageId,
imageHash: hash, imageHash: hash,
+18 -2
View File
@@ -1,12 +1,19 @@
import { ElectronAPI } from '@electron-toolkit/preload' import { ElectronAPI } from '@electron-toolkit/preload'
import { Contact, Message } from '../shared/types' import { Contact, Message } from '../shared/types'
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report' import {
GroupReportExportRequest,
GroupReportExportResult,
GroupReportRenderSnapshotExportRequest
} from '../shared/group-report'
import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test' import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test'
import { import {
DeleteGeneratedReportResult, DeleteGeneratedReportResult,
ReportHistoryResult, ReportHistoryResult,
SaveGeneratedReportRequest, SaveGeneratedReportRequest,
SaveGeneratedReportResult SaveGeneratedReportResult,
PrepareGeneratedReportTemplateSwitchResult,
UpdateGeneratedReportTemplateRequest,
UpdateGeneratedReportTemplateResult
} from '../shared/report-history' } from '../shared/report-history'
import type { import type {
DatabaseKeyEnvironment, DatabaseKeyEnvironment,
@@ -305,10 +312,19 @@ declare global {
revealExport: (path: string) => Promise<{ success: boolean; error?: string }> revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult> exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
exportGroupReportSnapshot: (
request: GroupReportRenderSnapshotExportRequest
) => Promise<GroupReportExportResult>
prepareGeneratedReportTemplateSwitch: (
reportId: string
) => Promise<PrepareGeneratedReportTemplateSwitchResult>
listGeneratedReports: () => Promise<ReportHistoryResult> listGeneratedReports: () => Promise<ReportHistoryResult>
saveGeneratedReport: ( saveGeneratedReport: (
request: SaveGeneratedReportRequest request: SaveGeneratedReportRequest
) => Promise<SaveGeneratedReportResult> ) => Promise<SaveGeneratedReportResult>
updateGeneratedReportTemplate: (
request: UpdateGeneratedReportTemplateRequest
) => Promise<UpdateGeneratedReportTemplateResult>
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult> deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult> getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
+14 -2
View File
@@ -1,7 +1,13 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload'
import type { GroupReportExportRequest } from '../shared/group-report' import type {
import type { SaveGeneratedReportRequest } from '../shared/report-history' GroupReportExportRequest,
GroupReportRenderSnapshotExportRequest
} from '../shared/group-report'
import type {
SaveGeneratedReportRequest,
UpdateGeneratedReportTemplateRequest
} from '../shared/report-history'
import type { import type {
AIChatRequestOptions, AIChatRequestOptions,
AiSearchExternalAuthorizationRequest, AiSearchExternalAuthorizationRequest,
@@ -206,9 +212,15 @@ const api = {
}, },
exportGroupReport: (request: GroupReportExportRequest) => exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request), ipcRenderer.invoke('report:export', request),
exportGroupReportSnapshot: (request: GroupReportRenderSnapshotExportRequest) =>
ipcRenderer.invoke('report:exportSnapshot', request),
prepareGeneratedReportTemplateSwitch: (reportId: string) =>
ipcRenderer.invoke('report:prepareTemplateSwitch', { reportId }),
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'), listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
saveGeneratedReport: (request: SaveGeneratedReportRequest) => saveGeneratedReport: (request: SaveGeneratedReportRequest) =>
ipcRenderer.invoke('report:saveGenerated', request), ipcRenderer.invoke('report:saveGenerated', request),
updateGeneratedReportTemplate: (request: UpdateGeneratedReportTemplateRequest) =>
ipcRenderer.invoke('report:updateGeneratedTemplate', request),
deleteGeneratedReport: (reportId: string) => deleteGeneratedReport: (reportId: string) =>
ipcRenderer.invoke('report:deleteGenerated', reportId), ipcRenderer.invoke('report:deleteGenerated', reportId),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
+46 -6
View File
@@ -31,6 +31,8 @@ import {
sortMessagesChronologically sortMessagesChronologically
} from './utils/message-pages' } from './utils/message-pages'
import { enrichQuotedMessages } from './utils/quoted-messages' import { enrichQuotedMessages } from './utils/quoted-messages'
import type { SelectableReportTemplateId } from '../../shared/report-templates'
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380 const SIDEBAR_MAX_WIDTH = 380
@@ -1462,7 +1464,9 @@ function App(): React.ReactElement {
reportGeneration.phase !== 'success' || reportGeneration.phase !== 'success' ||
!reportSourceContact || !reportSourceContact ||
!reportGeneration.generatedImage || !reportGeneration.generatedImage ||
!reportGeneration.reportPaths !reportGeneration.reportPaths ||
!reportGeneration.reportSnapshot ||
!reportGeneration.reportMetadata
) { ) {
return return
} }
@@ -1472,6 +1476,8 @@ function App(): React.ReactElement {
lastCapturedReportKeyRef.current = recordKey lastCapturedReportKeyRef.current = recordKey
setLatestGeneratedReportId(null) setLatestGeneratedReportId(null)
setIsSavingGeneratedReport(true) setIsSavingGeneratedReport(true)
const reportSnapshot = reportGeneration.reportSnapshot
const reportMetadata = reportGeneration.reportMetadata
const saveReport = async (): Promise<void> => { const saveReport = async (): Promise<void> => {
const result = await window.api.saveGeneratedReport({ const result = await window.api.saveGeneratedReport({
@@ -1492,7 +1498,10 @@ function App(): React.ReactElement {
duration: reportGeneration.generationMetadata.durationMs, duration: reportGeneration.generationMetadata.durationMs,
modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model, modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model,
tokenUsage: reportGeneration.generationMetadata.tokenUsage, tokenUsage: reportGeneration.generationMetadata.tokenUsage,
generationLogs: reportGeneration.generationMetadata.generationLogs generationLogs: reportGeneration.generationMetadata.generationLogs,
reportSnapshot,
reportMetadata,
templateId: reportGeneration.templateId
}) })
if (!result.success || !result.record) { if (!result.success || !result.record) {
@@ -1517,7 +1526,10 @@ function App(): React.ReactElement {
reportGeneration.generationMetadata, reportGeneration.generationMetadata,
reportGeneration.phase, reportGeneration.phase,
reportGeneration.reportMessages.length, reportGeneration.reportMessages.length,
reportGeneration.reportMetadata,
reportGeneration.reportPaths, reportGeneration.reportPaths,
reportGeneration.reportSnapshot,
reportGeneration.templateId,
reportSourceContact, reportSourceContact,
summaryDateRange summaryDateRange
]) ])
@@ -1564,6 +1576,30 @@ function App(): React.ReactElement {
return window.api.revealGroupReport(filePath) return window.api.revealGroupReport(filePath)
} }
const handleSwitchReportTemplate = async (
report: GeneratedReportRecord,
templateId: SelectableReportTemplateId
): Promise<{ success: boolean; error?: string }> => {
try {
const updated = await withTimeout(
switchGeneratedReportTemplate(report, templateId, window.api),
120_000,
'模板切换超时,请稍后重试'
)
if (!updated.success || !updated.record) {
return { success: false, error: updated.error || '日报模板更新失败' }
}
setGeneratedReports((current) =>
current.map((item) => (item.id === report.id ? updated.record! : item))
)
reportGeneration.setTemplateId(templateId)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
const handleDeleteReport = async ( const handleDeleteReport = async (
reportId: string reportId: string
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
@@ -1634,6 +1670,7 @@ function App(): React.ReactElement {
onRegenerate={handleRegenerateReport} onRegenerate={handleRegenerateReport}
onCopyImage={handleCopyReportImage} onCopyImage={handleCopyReportImage}
onReveal={handleRevealReport} onReveal={handleRevealReport}
onSwitchTemplate={handleSwitchReportTemplate}
/> />
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} /> <ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
</div> </div>
@@ -1686,10 +1723,13 @@ function App(): React.ReactElement {
error={reportGeneration.error} error={reportGeneration.error}
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress} voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')} voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
onRetry={() => { preparationProgress={reportGeneration.preparationProgress}
reportGeneration.resetGenerationStatus() imageInsightSummary={reportGeneration.imageInsightSummary}
void reportGeneration.retry() canRetryModelStep={reportGeneration.canRetryModelStep}
}} currentModel={aiModelConfig}
onRetry={(model) => void reportGeneration.retry(model)}
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
/> />
</div> </div>
) )
@@ -15,7 +15,7 @@ import { ReportRangeSelector } from './ReportRangeSelector'
import { ReportMemberNameSelector } from './ReportMemberNameSelector' import { ReportMemberNameSelector } from './ReportMemberNameSelector'
import { ReportGroupMemberSelector } from './ReportGroupMemberSelector' import { ReportGroupMemberSelector } from './ReportGroupMemberSelector'
import { ReportSectionSelector } from './ReportSectionSelector' import { ReportSectionSelector } from './ReportSectionSelector'
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector' import { ReportTemplateSelector, SelectableReportTemplateId } from './ReportTemplateSelector'
interface AiReportWorkspaceProps { interface AiReportWorkspaceProps {
sourceContact: Contact | null sourceContact: Contact | null
@@ -40,8 +40,8 @@ interface AiReportWorkspaceProps {
onRevealReport: () => Promise<{ success: boolean; error?: string }> onRevealReport: () => Promise<{ success: boolean; error?: string }>
onViewResult: () => void onViewResult: () => void
hasReportResult: boolean hasReportResult: boolean
templateId: ReportTemplateId templateId: SelectableReportTemplateId
onTemplateIdChange: (value: ReportTemplateId) => void onTemplateIdChange: (value: SelectableReportTemplateId) => void
memberNamePreference: ReportMemberNamePreference memberNamePreference: ReportMemberNamePreference
onMemberNamePreferenceChange: (value: ReportMemberNamePreference) => void onMemberNamePreferenceChange: (value: ReportMemberNamePreference) => void
reportTimeoutSeconds: number reportTimeoutSeconds: number
@@ -1,31 +1,89 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import type { AIProviderSummary } from '../../../../shared/ai-provider'
import { import {
AiModelConfig,
REPORT_TASK_STEPS, REPORT_TASK_STEPS,
ReportGenerationPhase, ReportGenerationPhase,
VoiceTranscriptionProgress VoiceTranscriptionProgress
} from '../../hooks/useGroupReportGeneration' } from '../../hooks/useGroupReportGeneration'
import type {
ReportImageInsightSummary,
ReportPreparationProgress
} from '../../utils/group-report-facts'
interface ReportTaskStatusPanelProps { interface ReportTaskStatusPanelProps {
phase: ReportGenerationPhase phase: ReportGenerationPhase
error: string error: string
voiceTranscriptionProgress: VoiceTranscriptionProgress | null voiceTranscriptionProgress: VoiceTranscriptionProgress | null
voiceTranscriptionEnabled: boolean voiceTranscriptionEnabled: boolean
onRetry: () => void preparationProgress: ReportPreparationProgress | null
imageInsightSummary: ReportImageInsightSummary
canRetryModelStep: boolean
currentModel: AiModelConfig
onRetry: (model?: AiModelConfig) => void
onContinueAfterImageFailures: () => void
onCancelAfterImageFailures: () => void
} }
interface ModelChoice {
key: string
label: string
config: AiModelConfig
}
const providerCanRun = (provider: AIProviderSummary): boolean =>
Boolean(
provider.models.some((model) => model.capabilities.chat) &&
(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none')
)
const modelChoices = (providers: AIProviderSummary[]): ModelChoice[] =>
providers.flatMap((provider) =>
providerCanRun(provider)
? provider.models
.filter((model) => model.capabilities.chat)
.map((model) => ({
key: `${provider.id}::${model.id}`,
label: `${provider.name} · ${model.name || model.id}`,
config: {
providerId: provider.id,
providerName: provider.name,
model: model.id,
modelName: model.name || model.id,
configured: true,
status: provider.status,
timeoutMs: provider.advanced.timeoutMs
}
}))
: []
)
export function ReportTaskStatusPanel({ export function ReportTaskStatusPanel({
phase, phase,
error, error,
voiceTranscriptionProgress, voiceTranscriptionProgress,
voiceTranscriptionEnabled, voiceTranscriptionEnabled,
onRetry preparationProgress,
imageInsightSummary,
canRetryModelStep,
currentModel,
onRetry,
onContinueAfterImageFailures,
onCancelAfterImageFailures
}: ReportTaskStatusPanelProps): React.ReactElement { }: ReportTaskStatusPanelProps): React.ReactElement {
const taskSteps = voiceTranscriptionEnabled const taskSteps = voiceTranscriptionEnabled
? REPORT_TASK_STEPS ? REPORT_TASK_STEPS
: REPORT_TASK_STEPS.filter((step) => step.id !== 'transcribingVoice') : REPORT_TASK_STEPS.filter((step) => step.id !== 'transcribingVoice')
const activeIndex = taskSteps.findIndex((step) => step.id === phase) const activeIndex = taskSteps.findIndex((step) => step.id === phase)
const effectiveActiveIndex =
phase === 'awaitingImageDecision'
? taskSteps.findIndex((step) => step.id === 'preparingInput')
: activeIndex
const completedAll = phase === 'success' const completedAll = phase === 'success'
const [logPath, setLogPath] = useState('') const [logPath, setLogPath] = useState('')
const [choices, setChoices] = useState<ModelChoice[]>([])
const [selectedModelKey, setSelectedModelKey] = useState('')
const [modelLoadError, setModelLoadError] = useState('')
useEffect(() => { useEffect(() => {
void window.api void window.api
@@ -34,6 +92,39 @@ export function ReportTaskStatusPanel({
.catch(() => undefined) .catch(() => undefined)
}, []) }, [])
useEffect(() => {
if (!canRetryModelStep) return
let active = true
void window.api
.listAIProviders()
.then((result) => {
if (!active) return
if (!result.success) {
setModelLoadError(result.error || '模型列表读取失败')
return
}
const nextChoices = modelChoices(result.providers)
setChoices(nextChoices)
const currentKey = `${currentModel.providerId || ''}::${currentModel.model}`
setSelectedModelKey(
nextChoices.some((choice) => choice.key === currentKey)
? currentKey
: nextChoices[0]?.key || ''
)
})
.catch((loadError) => {
if (active) {
setModelLoadError(loadError instanceof Error ? loadError.message : '模型列表读取失败')
}
})
return () => {
active = false
}
}, [canRetryModelStep, currentModel.model, currentModel.providerId])
const selectedModel = choices.find((choice) => choice.key === selectedModelKey)?.config
const imageDecisionPending = phase === 'awaitingImageDecision'
return ( return (
<aside className="report-task-panel"> <aside className="report-task-panel">
<div className="report-task-header"> <div className="report-task-header">
@@ -41,19 +132,21 @@ export function ReportTaskStatusPanel({
<p> <p>
{completedAll {completedAll
? '生成完成' ? '生成完成'
: phase === 'error' : imageDecisionPending
? '生成失败' ? '等待确认'
: activeIndex >= 0 : phase === 'error'
? `${activeIndex + 1}/${taskSteps.length}` ? '生成失败'
: '等待开始'} : effectiveActiveIndex >= 0
? `${effectiveActiveIndex + 1}/${taskSteps.length}`
: '等待开始'}
</p> </p>
</div> </div>
<div className="report-task-steps"> <div className="report-task-steps">
{taskSteps.map((step, index) => { {taskSteps.map((step, index) => {
const state = const state =
completedAll || (activeIndex >= 0 && index < activeIndex) completedAll || (effectiveActiveIndex >= 0 && index < effectiveActiveIndex)
? 'done' ? 'done'
: activeIndex === index : effectiveActiveIndex === index
? 'active' ? 'active'
: 'waiting' : 'waiting'
return ( return (
@@ -69,6 +162,76 @@ export function ReportTaskStatusPanel({
) )
})} })}
</div> </div>
{(phase === 'preparingInput' || phase === 'requestingModel' || imageDecisionPending) &&
preparationProgress && (
<div className="report-preparation-progress">
<div>
<strong>{preparationProgress.label}</strong>
{preparationProgress.total ? (
<span>
{preparationProgress.completed || 0}/{preparationProgress.total}
</span>
) : null}
</div>
{preparationProgress.total ? (
<progress
value={preparationProgress.completed || 0}
max={preparationProgress.total}
aria-label="图片识别进度"
/>
) : null}
</div>
)}
{imageInsightSummary.total > 0 && (
<details className="report-image-insights" open={imageDecisionPending}>
<summary>
{imageInsightSummary.succeeded}
{imageInsightSummary.failed > 0 ? ` · 失败 ${imageInsightSummary.failed}` : ''}
</summary>
<div className="report-image-insight-list">
{imageInsightSummary.items.map((item) => (
<article key={`${item.messageId}:${item.time}`}>
<div>
<b>{item.sender}</b>
<time>{item.time}</time>
</div>
<p>{item.description}</p>
{item.ocrText ? <small>OCR{item.ocrText}</small> : null}
{item.tags.length ? <small>{item.tags.join(' / ')}</small> : null}
</article>
))}
{imageInsightSummary.failures.map((failure, index) => (
<article
key={`${failure.messageId || failure.sender}:${failure.time || index}`}
className="failed"
>
<div>
<b>{failure.sender}</b>
{failure.time ? <time>{failure.time}</time> : null}
</div>
<p>{failure.error}</p>
</article>
))}
</div>
</details>
)}
{imageDecisionPending && (
<div className="report-image-decision">
<b> {imageInsightSummary.failed} </b>
<p>
{imageInsightSummary.succeeded}{' '}
</p>
<div>
<button type="button" onClick={onContinueAfterImageFailures}>
</button>
<button type="button" onClick={onCancelAfterImageFailures}>
</button>
</div>
</div>
)}
{phase === 'transcribingVoice' && voiceTranscriptionProgress && ( {phase === 'transcribingVoice' && voiceTranscriptionProgress && (
<div className="report-voice-progress"> <div className="report-voice-progress">
<div> <div>
@@ -92,9 +255,35 @@ export function ReportTaskStatusPanel({
<div className="report-task-error"> <div className="report-task-error">
<b></b> <b></b>
<p>{error}</p> <p>{error}</p>
<button type="button" onClick={onRetry}> {canRetryModelStep ? (
<div className="report-model-retry">
</button> <label htmlFor="report-retry-model"></label>
<select
id="report-retry-model"
value={selectedModelKey}
onChange={(event) => setSelectedModelKey(event.target.value)}
>
{choices.map((choice) => (
<option key={choice.key} value={choice.key}>
{choice.label}
</option>
))}
</select>
<small></small>
{modelLoadError ? <small className="error">{modelLoadError}</small> : null}
<button
type="button"
disabled={!selectedModel}
onClick={() => onRetry(selectedModel)}
>
使
</button>
</div>
) : (
<button type="button" onClick={() => onRetry()}>
</button>
)}
<button type="button" onClick={() => void window.api.revealAppLog()}> <button type="button" onClick={() => void window.api.revealAppLog()}>
</button> </button>
@@ -1,136 +1,159 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import {
DEFAULT_REPORT_TEMPLATE,
REPORT_TEMPLATES,
type ReportTemplateDefinition,
type SelectableReportTemplateId
} from '../../../../shared/report-templates'
export type ReportTemplateId = 'v1' | 'v2' export type { SelectableReportTemplateId } from '../../../../shared/report-templates'
interface TemplateMeta {
id: ReportTemplateId
label: string
tagline: string
preview: { title: string; sections: string[] }
}
const TEMPLATES: TemplateMeta[] = [
{
id: 'v1',
label: '模板1 · 经典日报',
tagline: '实用信息 / 重要消息 / 金句 / 问答 / 数据可视化',
preview: {
title: '经典日报',
sections: [
'今日讨论热点',
'AI 识别的图片精选',
'实用信息与资源',
'重要消息汇总',
'有趣对话或金句',
'问题与解答',
'群内数据可视化',
'词云/关键词'
]
}
},
{
id: 'v2',
label: '模板2 · 丰富日报',
tagline: '包含更多群聊分析板块;勾选图片后会尝试生成图片精选',
preview: {
title: '支持图片板块',
sections: [
'今日讨论热点',
'重要消息',
'待办事项和未解决问题',
'今日名场面',
'今日群数据',
'关键词',
'实用信息与资源',
'问题与解答',
'今日剧情时间线',
'群聊反转现场',
'AI 识别的图片精选',
'今日群相册',
'语音之最',
'语音时长榜',
'今日临时人设',
'话题参与链路'
]
}
}
]
interface ReportTemplateSelectorProps { interface ReportTemplateSelectorProps {
value: ReportTemplateId value: SelectableReportTemplateId
onChange: (value: ReportTemplateId) => void onChange: (value: SelectableReportTemplateId) => void
disabled?: boolean disabled?: boolean
} }
const TemplateDiagram = ({
template
}: {
template: ReportTemplateDefinition
}): React.ReactElement => (
<div className={`report-template-diagram diagram-${template.id}`} aria-hidden="true">
<div className="diagram-masthead">
<i />
<b />
<span />
</div>
<div className="diagram-kpis">
<i />
<i />
<i />
<i />
</div>
<div className="diagram-content">
<div className="diagram-column diagram-column-a">
<b />
<span />
<span />
</div>
<div className="diagram-column diagram-column-b">
<b />
<span />
<span />
<span />
</div>
<div className="diagram-column diagram-column-c">
<b />
<span />
<span />
</div>
</div>
</div>
)
export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
value, value,
onChange, onChange,
disabled disabled
}) => { }) => {
const [previewing, setPreviewing] = useState<TemplateMeta | null>(null) const [previewing, setPreviewing] = useState<ReportTemplateDefinition | null>(null)
return ( const mobileTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'mobile')
<section className="report-section"> const desktopTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'desktop')
<h3></h3>
<p className="report-section-desc"> const renderGroup = (
title: string,
</p> templates: readonly ReportTemplateDefinition[]
): React.ReactElement => (
<div className="report-template-group">
<div className="report-template-group-title">{title}</div>
<div className="report-template-list"> <div className="report-template-list">
{TEMPLATES.map((tpl) => { {templates.map((template) => {
const active = value === tpl.id const active = value === template.id
return ( return (
<div <div
key={tpl.id} key={template.id}
className={`report-template-item ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`} className={`report-template-item ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
> >
<label> <label>
<input <input
type="radio" type="radio"
name="report-template" name="report-template"
value={tpl.id} value={template.id}
checked={active} checked={active}
disabled={disabled} disabled={disabled}
onChange={() => onChange(tpl.id)} onChange={() => onChange(template.id)}
/> />
<TemplateDiagram template={template} />
<div className="report-template-body"> <div className="report-template-body">
<div className="report-template-title">{tpl.label}</div> <div className="report-template-eyebrow">{template.label}</div>
<div className="report-template-tagline">{tpl.tagline}</div> <div className="report-template-title">{template.name}</div>
<div className="report-template-tagline">{template.tagline}</div>
</div> </div>
</label> </label>
<button <button
type="button" type="button"
className="report-template-preview-btn" className="report-template-preview-btn"
onClick={() => setPreviewing(tpl)} onClick={() => setPreviewing(template)}
> >
</button> </button>
</div> </div>
) )
})} })}
</div> </div>
</div>
)
return (
<section className="report-section">
<h3></h3>
<p className="report-section-desc">
</p>
<div className="report-template-catalog">
{renderGroup('默认模板', [DEFAULT_REPORT_TEMPLATE])}
{renderGroup('手机端 · 375414 px', mobileTemplates)}
{renderGroup('电脑端 · 12801920 px', desktopTemplates)}
</div>
{previewing && ( {previewing && (
<div className="report-template-preview-mask" onClick={() => setPreviewing(null)}> <div className="report-template-preview-mask" onClick={() => setPreviewing(null)}>
<div className="report-template-preview-card" onClick={(e) => e.stopPropagation()}> <div
<h4>{previewing.preview.title}</h4> className="report-template-preview-card"
<p className="muted">{previewing.tagline}</p> onClick={(event) => event.stopPropagation()}
<div className="report-template-preview-frame"> >
<div className="fake-card fake-hero"> <div className="report-template-preview-heading">
<div className="fake-title"> · </div> <div>
<div className="fake-sub">2026-xx-xx · </div> <span>{previewing.label}</span>
<h4>{previewing.name}</h4>
</div> </div>
{previewing.preview.sections.map((s) => ( <em>
<div className="fake-card fake-section" key={s}> {previewing.platform === 'desktop'
<div className="fake-bar" /> ? '桌面宽屏'
<div className="fake-section-title">{s}</div> : previewing.platform === 'default'
</div> ? '经典长图'
))} : '手机长图'}
</em>
</div>
<p className="muted">{previewing.tagline}</p>
<TemplateDiagram template={previewing} />
<p className="report-template-preview-note">
Q&amp;A
</p>
<div className="report-template-preview-actions">
<button type="button" className="secondary" onClick={() => setPreviewing(null)}>
</button>
<button
type="button"
onClick={() => {
onChange(previewing.id)
setPreviewing(null)
}}
>
</button>
</div> </div>
<button
type="button"
className="report-template-preview-close"
onClick={() => setPreviewing(null)}
>
</button>
</div> </div>
</div> </div>
)} )}
@@ -1,8 +1,16 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import {
SELECTABLE_REPORT_TEMPLATES,
type SelectableReportTemplateId
} from '../../../../shared/report-templates'
interface ReportToolbarProps { interface ReportToolbarProps {
canCopyImage: boolean canCopyImage: boolean
canReveal: boolean canReveal: boolean
canSwitchTemplate: boolean
currentTemplateId?: SelectableReportTemplateId
isSwitchingTemplate: boolean
onSwitchTemplate: (templateId: SelectableReportTemplateId) => void
onRegenerate: () => void onRegenerate: () => void
onCopyImage: () => void onCopyImage: () => void
onReveal: () => void onReveal: () => void
@@ -11,24 +19,66 @@ interface ReportToolbarProps {
export function ReportToolbar({ export function ReportToolbar({
canCopyImage, canCopyImage,
canReveal, canReveal,
canSwitchTemplate,
currentTemplateId,
isSwitchingTemplate,
onSwitchTemplate,
onRegenerate, onRegenerate,
onCopyImage, onCopyImage,
onReveal onReveal
}: ReportToolbarProps): React.ReactElement { }: ReportToolbarProps): React.ReactElement {
const [moreOpen, setMoreOpen] = useState(false) const [moreOpen, setMoreOpen] = useState(false)
const [templateOpen, setTemplateOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const templateMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (!moreOpen) return if (!moreOpen && !templateOpen) return
const close = (event: PointerEvent): void => { const close = (event: PointerEvent): void => {
if (!menuRef.current?.contains(event.target as Node)) setMoreOpen(false) if (!menuRef.current?.contains(event.target as Node)) setMoreOpen(false)
if (!templateMenuRef.current?.contains(event.target as Node)) setTemplateOpen(false)
} }
window.addEventListener('pointerdown', close) window.addEventListener('pointerdown', close)
return () => window.removeEventListener('pointerdown', close) return () => window.removeEventListener('pointerdown', close)
}, [moreOpen]) }, [moreOpen, templateOpen])
return ( return (
<div className="report-viewer-toolbar"> <div className="report-viewer-toolbar">
<div className="report-template-switch-menu" ref={templateMenuRef}>
<button
type="button"
disabled={!canSwitchTemplate || isSwitchingTemplate}
title={
canSwitchTemplate
? '使用已生成的数据或本地 HTML 更换展示模板,不会重新调用 AI'
: '当前报告缺少可复用数据和 HTML,无法切换模板'
}
onClick={() => setTemplateOpen((open) => !open)}
>
{isSwitchingTemplate ? '切换中…' : '切换模板'}
</button>
{templateOpen && canSwitchTemplate && (
<div className="report-template-switch-popover" role="menu" aria-label="切换日报模板">
<p> AI</p>
{SELECTABLE_REPORT_TEMPLATES.map((template) => (
<button
type="button"
role="menuitem"
className={template.id === currentTemplateId ? 'active' : undefined}
key={template.id}
onClick={() => {
setTemplateOpen(false)
onSwitchTemplate(template.id)
}}
>
<span>{template.label}</span>
<b>{template.name}</b>
{template.id === currentTemplateId && <i></i>}
</button>
))}
</div>
)}
</div>
<button type="button" onClick={onRegenerate}> <button type="button" onClick={onRegenerate}>
</button> </button>
@@ -3,6 +3,7 @@ import type { GeneratedReportRecord } from './types'
import { ReportEmptyState } from './ReportEmptyState' import { ReportEmptyState } from './ReportEmptyState'
import { ReportToolbar } from './ReportToolbar' import { ReportToolbar } from './ReportToolbar'
import { ReportZoomBar } from './ReportZoomBar' import { ReportZoomBar } from './ReportZoomBar'
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
interface ReportViewerProps { interface ReportViewerProps {
report: GeneratedReportRecord | null report: GeneratedReportRecord | null
@@ -11,19 +12,43 @@ interface ReportViewerProps {
onRegenerate: () => void onRegenerate: () => void
onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }> onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }> onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
onSwitchTemplate: (
report: GeneratedReportRecord,
templateId: SelectableReportTemplateId
) => Promise<{ success: boolean; error?: string }>
} }
const calculateFitZoom = (
viewportWidth: number,
viewportHeight: number,
imageWidth: number,
imageHeight: number
): number =>
Math.min(
1,
Math.min(
Math.max(1, viewportWidth - 44) / imageWidth,
Math.max(1, viewportHeight - 44) / imageHeight
)
)
const normalizeFitZoom = (value: number): number =>
Math.max(0.0001, Math.floor(value * 10_000) / 10_000)
export function ReportViewer({ export function ReportViewer({
report, report,
hasReports, hasReports,
onBackToConfigure, onBackToConfigure,
onRegenerate, onRegenerate,
onCopyImage, onCopyImage,
onReveal onReveal,
onSwitchTemplate
}: ReportViewerProps): React.ReactElement { }: ReportViewerProps): React.ReactElement {
const [zoom, setZoom] = useState(1) const [zoom, setZoom] = useState(1)
const [fitZoom, setFitZoom] = useState(1)
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [imageError, setImageError] = useState('') const [imageError, setImageError] = useState('')
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null) const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
const viewportRef = useRef<HTMLDivElement>(null) const viewportRef = useRef<HTMLDivElement>(null)
@@ -31,7 +56,9 @@ export function ReportViewer({
const frame = window.requestAnimationFrame(() => { const frame = window.requestAnimationFrame(() => {
setStatus('') setStatus('')
setImageError('') setImageError('')
setIsSwitchingTemplate(false)
setZoom(1) setZoom(1)
setFitZoom(1)
setNaturalSize(null) setNaturalSize(null)
}) })
return () => window.cancelAnimationFrame(frame) return () => window.cancelAnimationFrame(frame)
@@ -39,13 +66,47 @@ export function ReportViewer({
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report]) const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
const fitWidth = (): void => { const measureFitZoom = (): number | null => {
const viewport = viewportRef.current const viewport = viewportRef.current
if (!viewport || !naturalSize?.width) return if (!viewport || !naturalSize?.width || !naturalSize.height) return null
const nextZoom = Math.min(2, Math.max(0.25, (viewport.clientWidth - 48) / naturalSize.width)) return calculateFitZoom(
setZoom(Number(nextZoom.toFixed(2))) viewport.clientWidth,
viewport.clientHeight,
naturalSize.width,
naturalSize.height
)
} }
const fitPage = (): void => {
const nextFitZoom = measureFitZoom()
if (!nextFitZoom) return
setFitZoom(normalizeFitZoom(nextFitZoom))
setZoom(1)
}
const showActualSize = (): void => {
if (!fitZoom) return
setZoom(Math.min(64, Math.max(0.25, Number((1 / fitZoom).toFixed(4)))))
}
useEffect(() => {
const viewport = viewportRef.current
if (!viewport || !naturalSize?.width || !naturalSize.height) return
const updateFitZoom = (): void => {
const nextFitZoom = calculateFitZoom(
viewport.clientWidth,
viewport.clientHeight,
naturalSize.width,
naturalSize.height
)
setFitZoom(normalizeFitZoom(nextFitZoom))
}
updateFitZoom()
const observer = new ResizeObserver(updateFitZoom)
observer.observe(viewport)
return () => observer.disconnect()
}, [naturalSize?.height, naturalSize?.width])
const handleCopy = async (): Promise<void> => { const handleCopy = async (): Promise<void> => {
if (!report) return if (!report) return
const result = await onCopyImage(report) const result = await onCopyImage(report)
@@ -58,6 +119,22 @@ export function ReportViewer({
setStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败') setStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败')
} }
const handleSwitchTemplate = async (templateId: SelectableReportTemplateId): Promise<void> => {
if (!report || isSwitchingTemplate) return
if (report.templateId === templateId) {
setStatus('当前已是所选模板')
return
}
setIsSwitchingTemplate(true)
setStatus('正在使用已有日报数据切换模板…')
try {
const result = await onSwitchTemplate(report, templateId)
setStatus(result.success ? '模板已切换,无需重新生成内容' : result.error || '模板切换失败')
} finally {
setIsSwitchingTemplate(false)
}
}
if (!report) { if (!report) {
return ( return (
<main className="report-viewer"> <main className="report-viewer">
@@ -88,6 +165,14 @@ export function ReportViewer({
<ReportToolbar <ReportToolbar
canCopyImage={Boolean(report.generatedImage)} canCopyImage={Boolean(report.generatedImage)}
canReveal={Boolean(report.pngPath || report.htmlPath)} canReveal={Boolean(report.pngPath || report.htmlPath)}
canSwitchTemplate={Boolean(
(report.reportSnapshot && report.reportMetadata) ||
report.reportRenderSnapshot ||
(report.htmlStatus === 'ready' && report.htmlPath)
)}
currentTemplateId={report.templateId}
isSwitchingTemplate={isSwitchingTemplate}
onSwitchTemplate={(templateId) => void handleSwitchTemplate(templateId)}
onRegenerate={onRegenerate} onRegenerate={onRegenerate}
onCopyImage={() => void handleCopy()} onCopyImage={() => void handleCopy()}
onReveal={() => void handleReveal()} onReveal={() => void handleReveal()}
@@ -101,7 +186,9 @@ export function ReportViewer({
src={report.generatedImage} src={report.generatedImage}
alt={title} alt={title}
style={{ style={{
width: naturalSize ? `${Math.round(naturalSize.width * zoom)}px` : undefined width: naturalSize
? `${Math.max(1, Math.round(naturalSize.width * fitZoom * zoom))}px`
: undefined
}} }}
onLoad={(event) => { onLoad={(event) => {
const image = event.currentTarget const image = event.currentTarget
@@ -111,9 +198,15 @@ export function ReportViewer({
} }
setNaturalSize(nextSize) setNaturalSize(nextSize)
const viewport = viewportRef.current const viewport = viewportRef.current
if (viewport && nextSize.width > viewport.clientWidth - 48) { if (viewport) {
const fittedZoom = Math.max(0.25, (viewport.clientWidth - 48) / nextSize.width) const fittedZoom = calculateFitZoom(
setZoom(Number(fittedZoom.toFixed(2))) viewport.clientWidth,
viewport.clientHeight,
nextSize.width,
nextSize.height
)
setFitZoom(normalizeFitZoom(fittedZoom))
setZoom(1)
} }
}} }}
onError={() => { onError={() => {
@@ -134,7 +227,12 @@ export function ReportViewer({
/> />
)} )}
</div> </div>
<ReportZoomBar zoom={zoom} onZoomChange={setZoom} onFitWidth={fitWidth} /> <ReportZoomBar
zoom={zoom}
onZoomChange={setZoom}
onFitPage={fitPage}
onActualSize={showActualSize}
/>
</main> </main>
) )
} }
@@ -3,27 +3,32 @@ import React from 'react'
interface ReportZoomBarProps { interface ReportZoomBarProps {
zoom: number zoom: number
onZoomChange: (zoom: number) => void onZoomChange: (zoom: number) => void
onFitWidth: () => void onFitPage: () => void
onActualSize: () => void
} }
const clampZoom = (value: number): number => Math.min(2, Math.max(0.25, value)) const clampZoom = (value: number): number => Math.min(64, Math.max(0.25, value))
export function ReportZoomBar({ export function ReportZoomBar({
zoom, zoom,
onZoomChange, onZoomChange,
onFitWidth onFitPage,
onActualSize
}: ReportZoomBarProps): React.ReactElement { }: ReportZoomBarProps): React.ReactElement {
return ( return (
<div className="report-zoom-bar"> <div className="report-zoom-bar">
<button type="button" onClick={() => onZoomChange(clampZoom(zoom - 0.1))}> <button type="button" onClick={() => onZoomChange(clampZoom(zoom / 1.25))}>
</button> </button>
<span>{Math.round(zoom * 100)}%</span> <span title="100% 为完整显示在当前预览框内">{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => onZoomChange(clampZoom(zoom + 0.1))}> <button type="button" onClick={() => onZoomChange(clampZoom(zoom * 1.25))}>
</button> </button>
<button type="button" onClick={onFitWidth}> <button type="button" onClick={onFitPage}>
</button>
<button type="button" onClick={onActualSize}>
</button> </button>
</div> </div>
) )
+3 -1
View File
@@ -4,7 +4,9 @@ export type {
ReportAssetStatus, ReportAssetStatus,
ReportHistoryResult, ReportHistoryResult,
SaveGeneratedReportRequest, SaveGeneratedReportRequest,
SaveGeneratedReportResult SaveGeneratedReportResult,
UpdateGeneratedReportTemplateRequest,
UpdateGeneratedReportTemplateResult
} from '../../../../shared/report-history' } from '../../../../shared/report-history'
export type ReportWorkspaceView = 'configure' | 'result' export type ReportWorkspaceView = 'configure' | 'result'
+349 -160
View File
@@ -11,7 +11,12 @@ import {
SummaryDateRange, SummaryDateRange,
SummaryMessageType SummaryMessageType
} from '../utils/group-report' } from '../utils/group-report'
import { ReportTemplateId } from '../components/reports/ReportTemplateSelector' import type { GroupDailyReport, GroupReportMetadata } from '../../../shared/group-report'
import type {
ReportImageInsightSummary,
ReportPreparationProgress
} from '../utils/group-report-facts'
import { SelectableReportTemplateId } from '../components/reports/ReportTemplateSelector'
import { import {
transcribeVoiceMessages as transcribeReportVoiceMessages, transcribeVoiceMessages as transcribeReportVoiceMessages,
type VoiceTranscriptionProgress type VoiceTranscriptionProgress
@@ -29,6 +34,7 @@ export type ReportGenerationPhase =
| 'loadingMessages' | 'loadingMessages'
| 'transcribingVoice' | 'transcribingVoice'
| 'preparingInput' | 'preparingInput'
| 'awaitingImageDecision'
| 'requestingModel' | 'requestingModel'
| 'exportingReport' | 'exportingReport'
| 'success' | 'success'
@@ -82,6 +88,12 @@ interface UseGroupReportGenerationArgs {
modelConfig: AiModelConfig modelConfig: AiModelConfig
} }
interface PreparedReportContext {
input: Awaited<ReturnType<typeof buildGroupReportInput>>
startedAt: number
logs: ReportGenerationLog[]
}
export interface ReportTaskStep { export interface ReportTaskStep {
id: Exclude<ReportGenerationPhase, 'idle' | 'success' | 'error'> id: Exclude<ReportGenerationPhase, 'idle' | 'success' | 'error'>
label: string label: string
@@ -100,6 +112,21 @@ export interface RangeMessageState {
error: string error: string
} }
const EMPTY_IMAGE_INSIGHT_SUMMARY: ReportImageInsightSummary = {
total: 0,
succeeded: 0,
failed: 0,
items: [],
failures: []
}
const createStepLog = (label: string, startedAt: Date, endedAt: Date): ReportGenerationLog => ({
label,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: endedAt.getTime() - startedAt.getTime()
})
const withTimeout = async <T>( const withTimeout = async <T>(
promise: Promise<T>, promise: Promise<T>,
label: string, label: string,
@@ -242,19 +269,27 @@ export function useGroupReportGeneration({
messageTypeCounts: Record<SummaryMessageType, number> messageTypeCounts: Record<SummaryMessageType, number>
rangeState: RangeMessageState rangeState: RangeMessageState
voiceTranscriptionProgress: VoiceTranscriptionProgress | null voiceTranscriptionProgress: VoiceTranscriptionProgress | null
preparationProgress: ReportPreparationProgress | null
imageInsightSummary: ReportImageInsightSummary
generatedImage: string | null generatedImage: string | null
reportPaths: ReportPaths | null reportPaths: ReportPaths | null
reportSnapshot: GroupDailyReport | null
reportMetadata: GroupReportMetadata | null
generationMetadata: ReportGenerationMetadata generationMetadata: ReportGenerationMetadata
isGenerating: boolean isGenerating: boolean
generate: () => Promise<void> generate: () => Promise<void>
retry: () => Promise<void> retry: (modelOverride?: AiModelConfig) => Promise<void>
continueAfterImageFailures: () => Promise<void>
cancelAfterImageFailures: () => void
canRetryModelStep: boolean
failedAt: string
resetGenerationStatus: () => void resetGenerationStatus: () => void
clearError: () => void clearError: () => void
closeResult: () => void closeResult: () => void
copyImage: () => Promise<{ success: boolean; error?: string }> copyImage: () => Promise<{ success: boolean; error?: string }>
revealReport: () => Promise<{ success: boolean; error?: string }> revealReport: () => Promise<{ success: boolean; error?: string }>
templateId: ReportTemplateId templateId: SelectableReportTemplateId
setTemplateId: (value: ReportTemplateId) => void setTemplateId: (value: SelectableReportTemplateId) => void
memberNamePreference: ReportMemberNamePreference memberNamePreference: ReportMemberNamePreference
setMemberNamePreference: (value: ReportMemberNamePreference) => void setMemberNamePreference: (value: ReportMemberNamePreference) => void
reportTimeoutSeconds: number reportTimeoutSeconds: number
@@ -266,9 +301,18 @@ export function useGroupReportGeneration({
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' }) const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
const [voiceTranscriptionProgress, setVoiceTranscriptionProgress] = const [voiceTranscriptionProgress, setVoiceTranscriptionProgress] =
useState<VoiceTranscriptionProgress | null>(null) useState<VoiceTranscriptionProgress | null>(null)
const [preparationProgress, setPreparationProgress] = useState<ReportPreparationProgress | null>(
null
)
const [imageInsightSummary, setImageInsightSummary] = useState<ReportImageInsightSummary>(
EMPTY_IMAGE_INSIGHT_SUMMARY
)
const [failedAt, setFailedAt] = useState('')
const [generatedImage, setGeneratedImage] = useState<string | null>(null) const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null) const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1') const [reportSnapshot, setReportSnapshot] = useState<GroupDailyReport | null>(null)
const [reportMetadata, setReportMetadata] = useState<GroupReportMetadata | null>(null)
const [templateId, setTemplateIdState] = useState<SelectableReportTemplateId>('v1')
const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>( const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>(
() => { () => {
const saved = localStorage.getItem('group_report_member_name_preference') const saved = localStorage.getItem('group_report_member_name_preference')
@@ -283,11 +327,15 @@ export function useGroupReportGeneration({
generationLogs: [] generationLogs: []
}) })
const rangeRequestIdRef = useRef(0) const rangeRequestIdRef = useRef(0)
const preparedContextRef = useRef<PreparedReportContext | null>(null)
const setMemberNamePreference = useCallback((value: ReportMemberNamePreference): void => { const setMemberNamePreference = useCallback((value: ReportMemberNamePreference): void => {
localStorage.setItem('group_report_member_name_preference', value) localStorage.setItem('group_report_member_name_preference', value)
setMemberNamePreferenceState(value) setMemberNamePreferenceState(value)
}, []) }, [])
const setTemplateId = useCallback((value: SelectableReportTemplateId): void => {
setTemplateIdState(value)
}, [])
const setReportTimeoutSeconds = useCallback((value: number): void => { const setReportTimeoutSeconds = useCallback((value: number): void => {
const normalized = Math.max(30, Math.min(1800, Math.round(Number(value) || 300))) const normalized = Math.max(30, Math.min(1800, Math.round(Number(value) || 300)))
localStorage.setItem('group_report_timeout_seconds', String(normalized)) localStorage.setItem('group_report_timeout_seconds', String(normalized))
@@ -298,6 +346,7 @@ export function useGroupReportGeneration({
phase === 'loadingMessages' || phase === 'loadingMessages' ||
phase === 'transcribingVoice' || phase === 'transcribingVoice' ||
phase === 'preparingInput' || phase === 'preparingInput' ||
phase === 'awaitingImageDecision' ||
phase === 'requestingModel' || phase === 'requestingModel' ||
phase === 'exportingReport' phase === 'exportingReport'
@@ -374,7 +423,13 @@ export function useGroupReportGeneration({
setError('') setError('')
setGeneratedImage(null) setGeneratedImage(null)
setReportPaths(null) setReportPaths(null)
setReportSnapshot(null)
setReportMetadata(null)
setVoiceTranscriptionProgress(null) setVoiceTranscriptionProgress(null)
setPreparationProgress(null)
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
setFailedAt('')
preparedContextRef.current = null
setGenerationMetadata({ generationLogs: [] }) setGenerationMetadata({ generationLogs: [] })
}, []) }, [])
@@ -396,45 +451,238 @@ export function useGroupReportGeneration({
[] []
) )
const runPreparedReport = useCallback(
async (context: PreparedReportContext, selectedModel: AiModelConfig): Promise<void> => {
if (!selectedModel.configured || !selectedModel.model) {
setFailedAt('调用模型生成内容')
setError('请选择一个已配置的 AI 模型')
setPhase('error')
return
}
let currentFailedAt = '调用模型生成内容'
const pushLog = (log: ReportGenerationLog): void => {
context.logs.push(log)
setGenerationMetadata({
modelName: selectedModel.model,
generationLogs: [...context.logs]
})
}
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
const startedAt = new Date()
try {
return await task()
} finally {
pushLog(createStepLog(label, startedAt, new Date()))
}
}
setError('')
setFailedAt('')
setPhase('requestingModel')
setPreparationProgress({ stage: 'summarizingInput', label: '整理总结中' })
setGenerationMetadata({
modelName: selectedModel.model,
generationLogs: [...context.logs]
})
writeReportLog('info', '调用模型生成日报内容', {
providerName: selectedModel.providerName,
model: selectedModel.model,
reusedPreparedInput: true,
imageInsights: context.input.imageInsightSummary.succeeded
})
try {
const aiMessages = [
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: context.input.prompt }
]
const result = await trackStep(`AI 生成(${selectedModel.model}`, () =>
withTimeout(
window.api.aiChat(aiMessages, {
providerId: selectedModel.providerId,
modelId: selectedModel.model,
timeoutMs: reportTimeoutSeconds * 1000
}),
'AI 生成日报',
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
)
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
writeReportLog('info', '模型响应完成', {
outputLength: result.data.length,
usage: result.usage
})
let tokenUsage =
result.usage && result.usage.total
? result.usage
: estimateTokenUsage(aiMessages, result.data)
let report: ReturnType<typeof parseGroupDailyReport>
try {
report = parseGroupDailyReport(
result.data,
context.input.topSpeakers,
context.input.activeTimeline,
context.input.voiceLeaderboard || [],
context.input.metadata,
context.input.media
)
} catch (parseError) {
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
...jsonErrorContext(result.data, parseError),
retry: 1
})
const repairMessages = [
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
{ role: 'user', content: result.data }
]
const repairResult = await trackStep(`AI 修复 JSON${selectedModel.model}`, () =>
withTimeout(
window.api.aiChat(repairMessages, {
providerId: selectedModel.providerId,
modelId: selectedModel.model,
timeoutMs: reportTimeoutSeconds * 1000
}),
'AI 修复日报 JSON',
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
)
)
if (!repairResult.success || !repairResult.data) {
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', {
cause: parseError
})
}
const repairUsage =
repairResult.usage && repairResult.usage.total
? repairResult.usage
: estimateTokenUsage(repairMessages, repairResult.data)
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
try {
report = parseGroupDailyReport(
repairResult.data,
context.input.topSpeakers,
context.input.activeTimeline,
context.input.voiceLeaderboard || [],
context.input.metadata,
context.input.media
)
writeReportLog('info', '模型已纠正日报 JSON', {
retry: 1,
outputLength: repairResult.data.length
})
} catch (retryParseError) {
writeReportLog(
'error',
'日报 JSON 重试后仍解析失败',
jsonErrorContext(repairResult.data, retryParseError)
)
throw retryParseError
}
}
setPhase('exportingReport')
currentFailedAt = '导出 HTML 与 PNG'
const exported = await withTimeout(
window.api.exportGroupReport({
report,
metadata: context.input.metadata,
templateId
}),
'日报图片导出'
)
if (
!exported.success ||
!exported.imageDataUrl ||
!exported.htmlPath ||
!exported.pngPath
) {
throw new Error(exported.error || '日报文件生成失败')
}
if (exported.exportTimings?.html) {
pushLog({ label: 'HTML 导出', ...exported.exportTimings.html })
}
if (exported.exportTimings?.png) {
pushLog({ label: 'PNG 导出', ...exported.exportTimings.png })
}
const exportFinishedAt =
exported.exportTimings?.png?.endedAt || exported.exportTimings?.html?.endedAt
const exportFinishTime = exportFinishedAt ? Date.parse(exportFinishedAt) : Date.now()
setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
setReportSnapshot(report)
setReportMetadata(context.input.metadata)
setGenerationMetadata({
durationMs:
Number.isFinite(exportFinishTime) && exportFinishTime > context.startedAt
? exportFinishTime - context.startedAt
: Date.now() - context.startedAt,
modelName: selectedModel.model,
tokenUsage,
generationLogs: [...context.logs]
})
preparedContextRef.current = null
setPreparationProgress(null)
setPhase('success')
writeReportLog('info', '群聊日报生成成功', {
durationMs: Date.now() - context.startedAt,
htmlPath: exported.htmlPath,
pngPath: exported.pngPath,
providerName: selectedModel.providerName,
model: selectedModel.model
})
} catch (generateError) {
const message = errorMessage(generateError)
writeReportLog('error', '群聊日报生成失败', {
error: message,
failedAt: currentFailedAt,
durationMs: Date.now() - context.startedAt,
reusablePreparedInput: currentFailedAt === '调用模型生成内容'
})
setFailedAt(currentFailedAt)
setError(message)
setPhase('error')
}
},
[reportTimeoutSeconds, templateId]
)
const generate = useCallback(async (): Promise<void> => { const generate = useCallback(async (): Promise<void> => {
if (isGenerating) return if (isGenerating) return
if (!sourceContact) { if (!sourceContact) {
setFailedAt('初始化')
setPhase('error') setPhase('error')
setError('请先选择一个群聊') setError('请先选择一个群聊')
return return
} }
if (!isGroupContact(sourceContact)) { if (!isGroupContact(sourceContact)) {
setFailedAt('初始化')
setPhase('error') setPhase('error')
setError('AI 群聊日报仅支持群聊') setError('AI 群聊日报仅支持群聊')
return return
} }
if (!modelConfig.configured) { if (!modelConfig.configured) {
setFailedAt('调用模型生成内容')
setPhase('error') setPhase('error')
setError('尚未配置可用的默认 AI 模型') setError('尚未配置可用的默认 AI 模型')
return return
} }
if (!summaryMessageTypes.length) { if (!summaryMessageTypes.length) {
setFailedAt('初始化')
setPhase('error') setPhase('error')
setError('请至少选择一种消息类型') setError('请至少选择一种消息类型')
return return
} }
const startGenerateTime = Date.now() const startGenerateTime = Date.now()
let failedAt = '初始化' let currentFailedAt = '初始化'
const logs: ReportGenerationLog[] = [] const logs: ReportGenerationLog[] = []
const pushLog = (log: ReportGenerationLog): void => { const pushLog = (log: ReportGenerationLog): void => {
logs.push(log) logs.push(log)
setGenerationMetadata({ setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [...logs] })
modelName: modelConfig.model,
generationLogs: [...logs]
})
} }
const createStepLog = (label: string, startedAt: Date, endedAt: Date): ReportGenerationLog => ({
label,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: endedAt.getTime() - startedAt.getTime()
})
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => { const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
const startedAt = new Date() const startedAt = new Date()
try { try {
@@ -444,13 +692,17 @@ export function useGroupReportGeneration({
} }
} }
preparedContextRef.current = null
setError('') setError('')
setFailedAt('')
setGeneratedImage(null) setGeneratedImage(null)
setReportPaths(null) setReportPaths(null)
setGenerationMetadata({ setReportSnapshot(null)
modelName: modelConfig.model, setReportMetadata(null)
generationLogs: [] setVoiceTranscriptionProgress(null)
}) setPreparationProgress(null)
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [] })
writeReportLog('info', '开始生成群聊日报', { writeReportLog('info', '开始生成群聊日报', {
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName, groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
dateRange: summaryDateRange, dateRange: summaryDateRange,
@@ -461,9 +713,8 @@ export function useGroupReportGeneration({
}) })
try { try {
failedAt = '读取聊天记录' currentFailedAt = '读取聊天记录'
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true)) const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes) const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type)) const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息') if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
@@ -473,7 +724,7 @@ export function useGroupReportGeneration({
}) })
setPhase(selectedTypes.has('语音') ? 'transcribingVoice' : 'preparingInput') setPhase(selectedTypes.has('语音') ? 'transcribingVoice' : 'preparingInput')
failedAt = '整理日报输入' currentFailedAt = '整理日报输入'
const input = await trackStep('整理输入', async () => { const input = await trackStep('整理输入', async () => {
const messagesWithTranscripts = selectedTypes.has('语音') const messagesWithTranscripts = selectedTypes.has('语音')
? await transcribeSelectedVoiceMessages(filteredMessages) ? await transcribeSelectedVoiceMessages(filteredMessages)
@@ -484,150 +735,40 @@ export function useGroupReportGeneration({
messagesWithTranscripts, messagesWithTranscripts,
memberNamePreference memberNamePreference
) )
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full') return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full', {
}) onProgress: setPreparationProgress
setPhase('requestingModel')
failedAt = '调用模型生成内容'
const aiMessages = [
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
]
const result = await trackStep('AI 生成', () =>
withTimeout(
window.api.aiChat(aiMessages, {
providerId: modelConfig.providerId,
modelId: modelConfig.model,
timeoutMs: reportTimeoutSeconds * 1000
}),
'AI 生成日报',
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
)
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
writeReportLog('info', '模型响应完成', {
outputLength: result.data.length,
usage: result.usage
})
let tokenUsage =
result.usage && result.usage.total
? result.usage
: estimateTokenUsage(aiMessages, result.data)
let report: ReturnType<typeof parseGroupDailyReport>
try {
report = parseGroupDailyReport(
result.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard || [],
input.metadata,
input.media
)
} catch (parseError) {
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
...jsonErrorContext(result.data, parseError),
retry: 1
}) })
const repairMessages = [
{
role: 'system',
content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT
},
{ role: 'user', content: result.data }
]
const repairResult = await trackStep('AI 修复 JSON', () =>
withTimeout(
window.api.aiChat(repairMessages, {
providerId: modelConfig.providerId,
modelId: modelConfig.model,
timeoutMs: reportTimeoutSeconds * 1000
}),
'AI 修复日报 JSON',
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
)
)
if (!repairResult.success || !repairResult.data) {
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', { cause: parseError })
}
const repairUsage =
repairResult.usage && repairResult.usage.total
? repairResult.usage
: estimateTokenUsage(repairMessages, repairResult.data)
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
try {
report = parseGroupDailyReport(
repairResult.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard || [],
input.metadata,
input.media
)
writeReportLog('info', '模型已纠正日报 JSON', {
retry: 1,
outputLength: repairResult.data.length
})
} catch (retryParseError) {
writeReportLog(
'error',
'日报 JSON 重试后仍解析失败',
jsonErrorContext(repairResult.data, retryParseError)
)
throw retryParseError
}
}
setPhase('exportingReport')
failedAt = '导出 HTML 与 PNG'
const exported = await withTimeout(
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
'日报图片导出'
)
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
throw new Error(exported.error || '日报文件生成失败')
}
if (exported.exportTimings?.html) {
pushLog({
label: 'HTML 导出',
...exported.exportTimings.html
})
}
if (exported.exportTimings?.png) {
pushLog({
label: 'PNG 导出',
...exported.exportTimings.png
})
}
const exportFinishedAt =
exported.exportTimings?.png?.endedAt || exported.exportTimings?.html?.endedAt
const exportFinishTime = exportFinishedAt ? Date.parse(exportFinishedAt) : Date.now()
setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
setGenerationMetadata({
durationMs:
Number.isFinite(exportFinishTime) && exportFinishTime > startGenerateTime
? exportFinishTime - startGenerateTime
: Date.now() - startGenerateTime,
modelName: modelConfig.model,
tokenUsage,
generationLogs: [...logs]
}) })
setPhase('success')
writeReportLog('info', '群聊日报生成成功', { setImageInsightSummary(input.imageInsightSummary)
durationMs: Date.now() - startGenerateTime, writeReportLog('info', '日报输入整理完成', {
htmlPath: exported.htmlPath, imageCandidates: input.imageInsightSummary.total,
pngPath: exported.pngPath imageInsightSucceeded: input.imageInsightSummary.succeeded,
imageInsightFailed: input.imageInsightSummary.failed,
imageInsightsInjectedIntoPrompt:
input.imageInsightSummary.succeeded > 0 && input.prompt.includes('AI 图片识别摘要:')
}) })
const context: PreparedReportContext = { input, startedAt: startGenerateTime, logs }
preparedContextRef.current = context
if (input.imageInsightSummary.failed > 0) {
setPreparationProgress({
stage: 'summarizingInput',
label: '等待确认是否继续文字总结',
completed: input.imageInsightSummary.succeeded,
total: input.imageInsightSummary.total
})
setPhase('awaitingImageDecision')
return
}
await runPreparedReport(context, modelConfig)
} catch (generateError) { } catch (generateError) {
const message = errorMessage(generateError) const message = errorMessage(generateError)
writeReportLog('error', '群聊日报生成失败', { writeReportLog('error', '群聊日报生成失败', {
error: message, error: message,
failedAt, failedAt: currentFailedAt,
durationMs: Date.now() - startGenerateTime durationMs: Date.now() - startGenerateTime
}) })
setFailedAt(currentFailedAt)
setError(message) setError(message)
setPhase('error') setPhase('error')
} }
@@ -636,7 +777,7 @@ export function useGroupReportGeneration({
loadRangeMessages, loadRangeMessages,
memberNamePreference, memberNamePreference,
modelConfig, modelConfig,
reportTimeoutSeconds, runPreparedReport,
sourceContact, sourceContact,
summaryDateRange, summaryDateRange,
summaryMessageTypes, summaryMessageTypes,
@@ -644,8 +785,48 @@ export function useGroupReportGeneration({
transcribeSelectedVoiceMessages transcribeSelectedVoiceMessages
]) ])
const retry = useCallback(
async (modelOverride?: AiModelConfig): Promise<void> => {
const context = preparedContextRef.current
if (failedAt === '调用模型生成内容' && context) {
await runPreparedReport(context, modelOverride || modelConfig)
return
}
await generate()
},
[failedAt, generate, modelConfig, runPreparedReport]
)
const continueAfterImageFailures = useCallback(async (): Promise<void> => {
const context = preparedContextRef.current
if (!context || phase !== 'awaitingImageDecision') return
writeReportLog('warn', '用户选择忽略图片识别失败并继续文字总结', {
imageInsightSucceeded: context.input.imageInsightSummary.succeeded,
imageInsightFailed: context.input.imageInsightSummary.failed
})
await runPreparedReport(context, modelConfig)
}, [modelConfig, phase, runPreparedReport])
const cancelAfterImageFailures = useCallback((): void => {
const summary = preparedContextRef.current?.input.imageInsightSummary
preparedContextRef.current = null
setError('')
setFailedAt('')
setPreparationProgress(null)
setPhase('idle')
writeReportLog('warn', '用户因图片识别失败停止本次日报生成', {
imageInsightSucceeded: summary?.succeeded || 0,
imageInsightFailed: summary?.failed || 0
})
}, [])
const canRetryModelStep =
phase === 'error' && failedAt === '调用模型生成内容' && Boolean(preparedContextRef.current)
const clearError = useCallback((): void => { const clearError = useCallback((): void => {
setError('') setError('')
setFailedAt('')
preparedContextRef.current = null
setPhase('idle') setPhase('idle')
}, []) }, [])
@@ -671,12 +852,20 @@ export function useGroupReportGeneration({
messageTypeCounts, messageTypeCounts,
rangeState, rangeState,
voiceTranscriptionProgress, voiceTranscriptionProgress,
preparationProgress,
imageInsightSummary,
generatedImage, generatedImage,
reportPaths, reportPaths,
reportSnapshot,
reportMetadata,
generationMetadata, generationMetadata,
isGenerating, isGenerating,
generate, generate,
retry: generate, retry,
continueAfterImageFailures,
cancelAfterImageFailures,
canRetryModelStep,
failedAt,
resetGenerationStatus, resetGenerationStatus,
clearError, clearError,
closeResult, closeResult,
+160
View File
@@ -649,6 +649,166 @@
font: 12px/18px var(--wxex-font); font: 12px/18px var(--wxex-font);
} }
.report-preparation-progress,
.report-image-insights,
.report-image-decision {
margin: 0 18px 14px;
padding: 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-main);
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-preparation-progress > div {
display: flex;
justify-content: space-between;
gap: 10px;
}
.report-preparation-progress strong,
.report-image-insights summary,
.report-image-decision b {
color: var(--wxex-text-primary);
font-weight: 700;
}
.report-preparation-progress progress {
display: block;
width: 100%;
height: 6px;
margin-top: 9px;
}
.report-image-insights {
padding: 0;
overflow: hidden;
}
.report-image-insights summary {
padding: 11px 12px;
cursor: pointer;
user-select: none;
}
.report-image-insight-list {
display: grid;
max-height: 300px;
overflow-y: auto;
border-top: 1px solid var(--wxex-border);
}
.report-image-insight-list article {
display: grid;
gap: 4px;
padding: 10px 12px;
border-bottom: 1px solid var(--wxex-border);
}
.report-image-insight-list article:last-child {
border-bottom: 0;
}
.report-image-insight-list article.failed {
background: #fff7f3;
}
.report-image-insight-list article > div {
display: flex;
justify-content: space-between;
gap: 8px;
}
.report-image-insight-list b {
min-width: 0;
overflow: hidden;
color: var(--wxex-text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.report-image-insight-list time,
.report-image-insight-list small {
color: var(--wxex-text-muted);
}
.report-image-insight-list p,
.report-image-decision p {
margin: 0;
overflow-wrap: anywhere;
}
.report-image-decision {
border-color: rgba(197, 137, 48, 0.4);
background: #fffaf0;
}
.report-image-decision p {
margin: 5px 0 10px;
}
.report-image-decision > div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.report-image-decision button,
.report-model-retry button {
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.report-image-decision button:first-child,
.report-model-retry button {
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #fff;
}
.report-model-retry {
display: grid;
gap: 7px;
margin-bottom: 10px;
}
.report-model-retry label {
color: var(--wxex-text-primary);
font-weight: 700;
}
.report-model-retry select {
width: 100%;
min-width: 0;
min-height: 34px;
padding: 0 9px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
font: 12px/18px var(--wxex-font);
}
.report-model-retry small {
color: var(--wxex-text-secondary);
}
.report-model-retry small.error {
color: var(--wxex-danger);
}
.report-model-retry button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.report-voice-progress { .report-voice-progress {
margin-top: 14px; margin-top: 14px;
padding: 12px; padding: 12px;
+355 -5
View File
@@ -259,6 +259,8 @@
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
@@ -292,6 +294,63 @@
position: relative; position: relative;
} }
.report-template-switch-menu {
position: relative;
}
.report-template-switch-popover {
position: absolute;
top: 38px;
right: 0;
z-index: 9;
width: 260px;
padding: 8px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: var(--wxex-shadow-popover);
}
.report-template-switch-popover p {
margin: 0 4px 6px;
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.report-template-switch-popover button {
display: grid;
width: 100%;
min-height: 42px;
grid-template-columns: 62px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: transparent;
text-align: left;
}
.report-template-switch-popover button:hover,
.report-template-switch-popover button.active {
background: var(--wxex-ai-soft);
}
.report-template-switch-popover button span,
.report-template-switch-popover button i {
color: var(--wxex-text-muted);
font: 11px/16px var(--wxex-font);
font-style: normal;
}
.report-template-switch-popover button b {
overflow: hidden;
color: var(--wxex-text-primary);
font: 600 13px/18px var(--wxex-font);
text-overflow: ellipsis;
white-space: nowrap;
}
.report-more-popover { .report-more-popover {
position: absolute; position: absolute;
top: 38px; top: 38px;
@@ -550,6 +609,23 @@
gap: 10px; gap: 10px;
} }
.report-template-catalog,
.report-template-group {
display: grid;
gap: 12px;
}
.report-template-catalog {
gap: 18px;
}
.report-template-group-title {
color: var(--wxex-text-muted);
font: 700 11px/16px var(--wxex-font);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.report-template-item { .report-template-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -582,6 +658,14 @@
min-width: 0; min-width: 0;
} }
.report-template-eyebrow {
margin-bottom: 2px;
color: var(--wxex-brand);
font: 800 10px/14px var(--wxex-font);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.report-template-item input[type='radio'] { .report-template-item input[type='radio'] {
accent-color: var(--wxex-primary, #07c160); accent-color: var(--wxex-primary, #07c160);
} }
@@ -597,6 +681,190 @@
color: var(--wxex-text-primary, #1f2933); color: var(--wxex-text-primary, #1f2933);
} }
.report-template-diagram {
display: grid;
width: 72px;
height: 54px;
flex: 0 0 auto;
gap: 4px;
overflow: hidden;
padding: 6px;
border: 1px solid #dce5df;
border-radius: 8px;
background: #f8faf8;
}
.diagram-masthead,
.diagram-kpis,
.diagram-content,
.diagram-column {
display: grid;
min-width: 0;
gap: 2px;
}
.diagram-masthead {
grid-template-columns: 1fr 18px;
}
.diagram-masthead i,
.diagram-masthead b,
.diagram-masthead span,
.diagram-kpis i,
.diagram-column b,
.diagram-column span {
display: block;
min-width: 0;
min-height: 2px;
border-radius: 2px;
background: #b9c9c0;
}
.diagram-masthead i {
height: 4px;
background: #278861;
}
.diagram-masthead b {
height: 4px;
}
.diagram-masthead span {
grid-column: 1 / -1;
width: 72%;
height: 3px;
}
.diagram-kpis {
grid-template-columns: repeat(4, 1fr);
}
.diagram-kpis i {
height: 7px;
background: #dceae2;
}
.diagram-content {
grid-template-columns: 1fr;
}
.diagram-column b {
height: 8px;
background: #8ab6a0;
}
.diagram-column span {
height: 4px;
background: #d5dfd9;
}
.diagram-mobile-feed .diagram-content {
grid-template-columns: 1fr;
}
.diagram-v1 {
background: #f5f7f8;
}
.diagram-v1 .diagram-masthead i {
background: #3b82f6;
}
.diagram-v1 .diagram-kpis i {
background: #e5edf6;
}
.diagram-v1 .diagram-column-b,
.diagram-v1 .diagram-column-c {
display: none;
}
.diagram-mobile-feed .diagram-column-b,
.diagram-mobile-feed .diagram-column-c {
display: none;
}
.diagram-mobile-magazine {
border-radius: 2px;
background: #fbf7ef;
}
.diagram-mobile-magazine .diagram-masthead i {
height: 7px;
background: #2c2925;
}
.diagram-mobile-magazine .diagram-kpis i {
border-radius: 0;
background: transparent;
border-top: 1px solid #574f45;
border-bottom: 1px solid #574f45;
}
.diagram-mobile-magazine .diagram-column-b,
.diagram-mobile-magazine .diagram-column-c {
display: none;
}
.diagram-mobile-dashboard {
border-color: #30453b;
border-radius: 3px;
background: #13201b;
}
.diagram-mobile-dashboard .diagram-masthead i,
.diagram-mobile-dashboard .diagram-column b {
background: #55e69a;
}
.diagram-mobile-dashboard .diagram-kpis i {
border: 1px solid #30453b;
border-radius: 2px;
background: #1c2d26;
}
.diagram-mobile-dashboard .diagram-column span {
background: #385347;
}
.diagram-mobile-dashboard .diagram-column-b,
.diagram-mobile-dashboard .diagram-column-c {
display: none;
}
.diagram-desktop-workspace {
width: 90px;
}
.diagram-desktop-workspace .diagram-content {
grid-template-columns: 0.72fr 1.45fr 0.8fr;
}
.diagram-desktop-editorial {
width: 90px;
border-radius: 2px;
background: #fbf6eb;
}
.diagram-desktop-editorial .diagram-masthead i {
height: 7px;
border-radius: 0;
background: #332b23;
}
.diagram-desktop-editorial .diagram-content {
grid-template-columns: 1.7fr 0.8fr;
}
.diagram-desktop-editorial .diagram-column-c {
display: none;
}
.diagram-desktop-editorial .diagram-column b {
border-radius: 0;
background: #9a4b38;
}
.report-template-tagline { .report-template-tagline {
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
@@ -641,12 +909,80 @@
background: #f3f5f7; background: #f3f5f7;
border-radius: 18px; border-radius: 18px;
padding: 18px; padding: 18px;
width: min(380px, 100%); width: min(520px, 100%);
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25); box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25);
} }
.report-template-preview-card > .report-template-diagram {
width: 100%;
height: 260px;
padding: 18px;
gap: 10px;
border-radius: 12px;
}
.report-template-preview-card > .diagram-v1,
.report-template-preview-card > .diagram-mobile-feed,
.report-template-preview-card > .diagram-mobile-magazine,
.report-template-preview-card > .diagram-mobile-dashboard {
width: 180px;
justify-self: center;
}
.report-template-preview-card > .report-template-diagram .diagram-masthead,
.report-template-preview-card > .report-template-diagram .diagram-kpis,
.report-template-preview-card > .report-template-diagram .diagram-content,
.report-template-preview-card > .report-template-diagram .diagram-column {
gap: 6px;
}
.report-template-preview-card > .report-template-diagram .diagram-masthead i,
.report-template-preview-card > .report-template-diagram .diagram-masthead b {
height: 13px;
}
.report-template-preview-card > .report-template-diagram .diagram-masthead span {
height: 7px;
}
.report-template-preview-card > .report-template-diagram .diagram-kpis i {
height: 30px;
}
.report-template-preview-card > .report-template-diagram .diagram-column b {
height: 45px;
}
.report-template-preview-card > .report-template-diagram .diagram-column span {
height: 20px;
}
.report-template-preview-heading,
.report-template-preview-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.report-template-preview-heading span,
.report-template-preview-heading em {
color: var(--wxex-text-muted);
font: 700 10px/14px var(--wxex-font);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.report-template-preview-heading em {
padding: 4px 7px;
border-radius: 999px;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-style: normal;
}
.report-template-preview-card h4 { .report-template-preview-card h4 {
margin: 0 0 6px; margin: 0 0 6px;
font-size: 17px; font-size: 17px;
@@ -710,10 +1046,19 @@
color: #1f2933; color: #1f2933;
} }
.report-template-preview-close { .report-template-preview-note {
margin: 14px 0 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-template-preview-actions {
justify-content: flex-end;
margin-top: 14px; margin-top: 14px;
width: 100%; }
padding: 10px;
.report-template-preview-actions button {
padding: 9px 14px;
border-radius: 10px; border-radius: 10px;
border: none; border: none;
background: var(--wxex-primary, #07c160); background: var(--wxex-primary, #07c160);
@@ -722,6 +1067,12 @@
cursor: pointer; cursor: pointer;
} }
.report-template-preview-actions button.secondary {
border: 1px solid var(--wxex-border);
background: #fff;
color: var(--wxex-text-primary);
}
.settings-auto-login-card { .settings-auto-login-card {
padding: 18px 20px; padding: 18px 20px;
} }
@@ -818,4 +1169,3 @@
color: var(--wxex-text-muted); color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font); font: 11px/17px var(--wxex-font);
} }
+161 -55
View File
@@ -3,7 +3,6 @@ import {
GroupDailyReport, GroupDailyReport,
GroupReportMetadata, GroupReportMetadata,
ReportFunBadge, ReportFunBadge,
ReportMediaGalleryItem,
ReportMode, ReportMode,
ReportSpeakerRank, ReportSpeakerRank,
ReportVisionGalleryItem, ReportVisionGalleryItem,
@@ -16,6 +15,7 @@ import type {
ImageCandidate, ImageCandidate,
ImageCandidateQuery ImageCandidateQuery
} from '../../../shared/image-insight' } from '../../../shared/image-insight'
import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight'
interface ReportImageReadResult { interface ReportImageReadResult {
success: boolean success: boolean
@@ -57,6 +57,42 @@ export interface GroupReportFactsSnapshot {
media: GroupDailyReport['media'] media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[] voiceLeaderboard: ReportVoiceLeaderboardItem[]
factsPrompt: string factsPrompt: string
imageInsightSummary: ReportImageInsightSummary
}
export interface ReportImageInsightItem {
messageId: string
sender: string
time: string
description: string
ocrText?: string
tags: string[]
}
export interface ReportImageInsightFailure {
messageId?: string
sender: string
time?: string
error: string
}
export interface ReportImageInsightSummary {
total: number
succeeded: number
failed: number
items: ReportImageInsightItem[]
failures: ReportImageInsightFailure[]
}
export interface ReportPreparationProgress {
stage: 'selectingImages' | 'recognizingImages' | 'summarizingInput'
label: string
completed?: number
total?: number
}
export interface BuildGroupReportFactsOptions {
onProgress?: (progress: ReportPreparationProgress) => void
} }
function friendlyImageNotice(warnings: string[]): string { function friendlyImageNotice(warnings: string[]): string {
@@ -180,12 +216,14 @@ const buildImageContext = (
stats: string stats: string
responseCount: number responseCount: number
participantCount: number participantCount: number
interactionCount: number
snippets: string[] snippets: string[]
} => { } => {
const baseTime = parseTimestamp(messages[index]) const baseTime = parseTimestamp(messages[index])
const participants = new Set<string>() const participants = new Set<string>()
const snippets: string[] = [] const snippets: string[] = []
let responseCount = 0 let responseCount = 0
let interactionCount = 0
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) { for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
const candidate = messages[offset] const candidate = messages[offset]
@@ -200,6 +238,9 @@ const buildImageContext = (
responseCount += 1 responseCount += 1
participants.add(sender) participants.add(sender)
} }
if (candidate.contentData?.type === 'sticker' || candidate.contentData?.type === 'voice') {
interactionCount += 1
}
if ( if (
snippets.length < 3 && snippets.length < 3 &&
!content.startsWith('[图片]') && !content.startsWith('[图片]') &&
@@ -226,6 +267,7 @@ const buildImageContext = (
stats: statsParts.join(' · '), stats: statsParts.join(' · '),
responseCount, responseCount,
participantCount: participants.size, participantCount: participants.size,
interactionCount,
snippets snippets
} }
} }
@@ -234,13 +276,17 @@ const buildMediaSection = async (
messages: Message[], messages: Message[],
contact: Contact | null, contact: Contact | null,
isGroup: boolean, isGroup: boolean,
topSpeakersMap: Map<string, number> topSpeakersMap: Map<string, number>,
options: BuildGroupReportFactsOptions = {}
): Promise<{ ): Promise<{
media: GroupDailyReport['media'] media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[] voiceLeaderboard: ReportVoiceLeaderboardItem[]
warnings: string[] warnings: string[]
imageInsightSummary: ReportImageInsightSummary
}> => { }> => {
const warnings: string[] = [] const warnings: string[] = []
const imageFailures: ReportImageInsightFailure[] = []
let imageCandidateTotal = 0
const rendererApi = typeof window === 'undefined' ? null : window.api const rendererApi = typeof window === 'undefined' ? null : window.api
const rawImageCandidates = messages const rawImageCandidates = messages
.map((message, index) => { .map((message, index) => {
@@ -258,21 +304,31 @@ const buildMediaSection = async (
stats: context.stats, stats: context.stats,
replyCount: context.responseCount, replyCount: context.responseCount,
participantCount: context.participantCount, participantCount: context.participantCount,
score: context.responseCount * 3 + context.participantCount * 2 + 1 interactionCount: context.interactionCount,
score: calculateImageHeatScore({
responseCount: context.responseCount,
interactionCount: context.interactionCount
}),
isHot: isHotImageCandidate({
responseCount: context.responseCount,
interactionCount: context.participantCount
})
} }
}) })
.filter((item): item is NonNullable<typeof item> => Boolean(item)) .filter((item): item is NonNullable<typeof item> => Boolean(item))
.filter((item) => item.isHot)
.sort((left, right) => right.score - left.score) .sort((left, right) => right.score - left.score)
.slice(0, 6) .slice(0, 6)
// ============================================================ // ============================================================
// AI 图片理解(ImageInsightService 接入) // AI 图片理解(ImageInsightService 接入)
// 通过 main 进程拿 Top 3 热点图 + 已缓存的 Insight;未缓存的并发调 AI // 通过 main 进程拿最多 3 张真正的热点图 + 已缓存的 Insight;
// 失败不阻塞:任何错误只记日志,降级到原 gallery // 未缓存的并发调 AI。失败不阻塞文字日报。
// ============================================================ // ============================================================
let visionGallery: ReportVisionGalleryItem[] = [] let visionGallery: ReportVisionGalleryItem[] = []
try { try {
if (!rendererApi) throw new Error('后台模式不读取 Renderer 图片') if (!rendererApi) throw new Error('后台模式不读取 Renderer 图片')
options.onProgress?.({ stage: 'selectingImages', label: '筛选热点图片' })
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '') const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
const startTime = messages.length ? parseTimestamp(messages[0]) : 0 const startTime = messages.length ? parseTimestamp(messages[0]) : 0
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0 const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
@@ -288,7 +344,7 @@ const buildMediaSection = async (
sender: c.sender, sender: c.sender,
sentAt: parseTimestamp(srcMsg), sentAt: parseTimestamp(srcMsg),
responseCount: c.replyCount || 0, responseCount: c.replyCount || 0,
interactionCount: c.participantCount || 0 interactionCount: c.interactionCount || 0
} }
}) })
@@ -300,12 +356,45 @@ const buildMediaSection = async (
inputs: imageInputs inputs: imageInputs
}) })
const candidates = candidatesResp.success ? candidatesResp.candidates : [] const candidates = candidatesResp.success ? candidatesResp.candidates : []
imageCandidateTotal = candidates.length
console.log('[buildMediaSection] imageListCandidates returned', candidates.length, 'candidates') console.log('[buildMediaSection] imageListCandidates returned', candidates.length, 'candidates')
if (!candidatesResp.success && rawImageCandidates.length) {
imageCandidateTotal = Math.min(3, rawImageCandidates.length)
for (const candidate of rawImageCandidates.slice(0, imageCandidateTotal)) {
imageFailures.push({
messageId: candidate.sourceMessageIds[0],
sender: candidate.sender,
time: candidate.time,
error: candidatesResp.error || '热点图片筛选失败'
})
}
}
options.onProgress?.({
stage: 'recognizingImages',
label: candidates.length ? '识别图片中' : '未找到可识别的热点图片',
completed: 0,
total: candidates.length
})
// 对每个候选:缓存命中直接用,未命中并发调 imageAnalyze // 对每个候选:缓存命中直接用,未命中并发调 imageAnalyze
let analyzedCount = 0
const analyzed = await Promise.all( const analyzed = await Promise.all(
candidates.map(async (candidate) => { candidates.map(async (candidate) => {
if (candidate.insight) return candidate.insight const finish = (): void => {
analyzedCount += 1
options.onProgress?.({
stage: 'recognizingImages',
label: '识别图片中',
completed: analyzedCount,
total: candidates.length
})
}
if (candidate.insight) {
finish()
return candidate.insight
}
// 未命中:解密图片拿 base64 → 调 AI // 未命中:解密图片拿 base64 → 调 AI
try { try {
const img = await rendererApi.getImage( const img = await rendererApi.getImage(
@@ -318,6 +407,12 @@ const buildMediaSection = async (
warnings.push( warnings.push(
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片读取失败:${img.error || '未知错误'}` `${candidate.sender} ${localTime(candidate.sentAt)} 的图片读取失败:${img.error || '未知错误'}`
) )
imageFailures.push({
messageId: candidate.messageId,
sender: candidate.sender,
time: localTime(candidate.sentAt),
error: img.error || '图片读取失败'
})
return null return null
} }
const analyzeResp = await rendererApi.imageAnalyze({ const analyzeResp = await rendererApi.imageAnalyze({
@@ -333,6 +428,12 @@ const buildMediaSection = async (
warnings.push( warnings.push(
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别失败:${analyzeResp.error || '模型未返回识别结果'}` `${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别失败:${analyzeResp.error || '模型未返回识别结果'}`
) )
imageFailures.push({
messageId: candidate.messageId,
sender: candidate.sender,
time: localTime(candidate.sentAt),
error: analyzeResp.error || '模型未返回识别结果'
})
return null return null
} }
return analyzeResp.insight return analyzeResp.insight
@@ -341,7 +442,15 @@ const buildMediaSection = async (
warnings.push( warnings.push(
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别异常:${error instanceof Error ? error.message : String(error)}` `${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别异常:${error instanceof Error ? error.message : String(error)}`
) )
imageFailures.push({
messageId: candidate.messageId,
sender: candidate.sender,
time: localTime(candidate.sentAt),
error: error instanceof Error ? error.message : String(error)
})
return null return null
} finally {
finish()
} }
}) })
) )
@@ -382,45 +491,26 @@ const buildMediaSection = async (
} catch (error) { } catch (error) {
console.warn('[buildMediaSection] vision flow failed, fallback to empty:', error) console.warn('[buildMediaSection] vision flow failed, fallback to empty:', error)
warnings.push(`图片识别流程失败:${error instanceof Error ? error.message : String(error)}`) warnings.push(`图片识别流程失败:${error instanceof Error ? error.message : String(error)}`)
if (!imageFailures.length && rawImageCandidates.length) {
imageCandidateTotal = Math.min(3, rawImageCandidates.length)
for (const candidate of rawImageCandidates.slice(0, imageCandidateTotal)) {
imageFailures.push({
messageId: candidate.sourceMessageIds[0],
sender: candidate.sender,
time: candidate.time,
error: error instanceof Error ? error.message : String(error)
})
}
}
visionGallery = [] visionGallery = []
} }
const imageCandidates = rendererApi options.onProgress?.({
? await Promise.all( stage: 'summarizingInput',
rawImageCandidates.map(async (item) => { label: '汇总图片识别结果',
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId, { completed: visionGallery.length,
includeData: true total: imageCandidateTotal
}) })
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((item) => ({
sender: item.sender,
time: item.time,
imageUrl: item.imageUrl,
note: item.note,
stats: item.stats,
inferenceLabel: item.inferenceLabel,
sourceMessageIds: item.sourceMessageIds,
replyCount: item.replyCount
}))
const voiceMessages = messages const voiceMessages = messages
.filter((message) => message.contentData?.type === 'voice') .filter((message) => message.contentData?.type === 'voice')
@@ -495,11 +585,11 @@ const buildMediaSection = async (
note: `今天一共发了 ${topSpeaker[1]} 条消息。` note: `今天一共发了 ${topSpeaker[1]} 条消息。`
}) })
} }
if (gallery[0]) { if (visionGallery[0]) {
funBadges.push({ funBadges.push({
title: '图片话题王', title: '图片话题王',
owner: gallery[0].sender, owner: visionGallery[0].sender,
note: `${gallery[0].time}图片带动了最明显的一轮讨论` note: `${visionGallery[0].time}热点图片进入了 AI 图片精选`
}) })
} }
if (voiceLeaderboard[0]) { if (voiceLeaderboard[0]) {
@@ -512,13 +602,28 @@ const buildMediaSection = async (
return { return {
media: { media: {
gallery, // 保留旧字段以兼容历史报告,但新日报不再生成或读取“群聊相册”。
gallery: [],
visionGallery, visionGallery,
voiceHighlights: voiceHighlights.slice(0, 2), voiceHighlights: voiceHighlights.slice(0, 2),
funBadges: funBadges.slice(0, 3) funBadges: funBadges.slice(0, 3)
}, },
voiceLeaderboard, voiceLeaderboard,
warnings warnings,
imageInsightSummary: {
total: imageCandidateTotal,
succeeded: visionGallery.length,
failed: imageFailures.length,
items: visionGallery.map((item) => ({
messageId: item.messageId,
sender: item.sender,
time: item.time,
description: item.description,
ocrText: item.ocrText,
tags: item.tags
})),
failures: imageFailures
}
} }
} }
@@ -561,7 +666,8 @@ export const buildGroupReportFacts = async (
messages: Message[], messages: Message[],
contact: Contact | null, contact: Contact | null,
isGroup: boolean, isGroup: boolean,
reportMode: ReportMode reportMode: ReportMode,
options: BuildGroupReportFactsOptions = {}
): Promise<GroupReportFactsSnapshot> => { ): Promise<GroupReportFactsSnapshot> => {
const transcriptRows = messages.map((message) => ({ const transcriptRows = messages.map((message) => ({
id: message.id, id: message.id,
@@ -665,11 +771,12 @@ export const buildGroupReportFacts = async (
reportMode reportMode
} }
const { media, voiceLeaderboard, warnings } = await buildMediaSection( const { media, voiceLeaderboard, warnings, imageInsightSummary } = await buildMediaSection(
messages, messages,
contact, contact,
isGroup, isGroup,
speakerCounts speakerCounts,
options
) )
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings] if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
if (imageCount > 0 && !media.visionGallery?.length) { if (imageCount > 0 && !media.visionGallery?.length) {
@@ -681,6 +788,7 @@ export const buildGroupReportFacts = async (
if ( if (
transcriptRows.length > 0 && transcriptRows.length > 0 &&
transcriptRows.every((row) => row.content === '[图片]') && transcriptRows.every((row) => row.content === '[图片]') &&
imageInsightSummary.total > 0 &&
!media.visionGallery?.length !media.visionGallery?.length
) { ) {
throw new Error( throw new Error(
@@ -692,9 +800,6 @@ export const buildGroupReportFacts = async (
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`, `报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`, `消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
activeTimeline ? `活跃时段:${activeTimeline}` : '', activeTimeline ? `活跃时段:${activeTimeline}` : '',
media.gallery.length
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats}`).join('')}`
: '',
// AI 图片理解结果(由 ImageInsightService 提供,缓存命中或已调用 Vision) // AI 图片理解结果(由 ImageInsightService 提供,缓存命中或已调用 Vision)
(media.visionGallery?.length ?? 0) > 0 (media.visionGallery?.length ?? 0) > 0
? `AI 图片识别摘要:${(media.visionGallery || []) ? `AI 图片识别摘要:${(media.visionGallery || [])
@@ -727,6 +832,7 @@ export const buildGroupReportFacts = async (
activeTimeline, activeTimeline,
media, media,
voiceLeaderboard, voiceLeaderboard,
factsPrompt factsPrompt,
imageInsightSummary
} }
} }
+12 -68
View File
@@ -21,6 +21,7 @@ import {
import { Contact, Message } from '../../../shared/types' import { Contact, Message } from '../../../shared/types'
import { jsonrepair } from 'jsonrepair' import { jsonrepair } from 'jsonrepair'
import { buildGroupReportFacts } from './group-report-facts' import { buildGroupReportFacts } from './group-report-facts'
import type { BuildGroupReportFactsOptions, ReportImageInsightSummary } from './group-report-facts'
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。 export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
@@ -85,6 +86,7 @@ export interface GroupReportInput {
activeTimeline: string activeTimeline: string
voiceLeaderboard: ReportVoiceLeaderboardItem[] voiceLeaderboard: ReportVoiceLeaderboardItem[]
media: GroupDailyReport['media'] media: GroupDailyReport['media']
imageInsightSummary: ReportImageInsightSummary
} }
const REPORT_MODE_LABEL: Record<ReportMode, string> = { const REPORT_MODE_LABEL: Record<ReportMode, string> = {
@@ -103,7 +105,6 @@ interface ReportModeConfig {
maxStorylines: number maxStorylines: number
maxReversals: number maxReversals: number
maxChains: number maxChains: number
maxGallery: number
maxVoiceHighlights: number maxVoiceHighlights: number
maxBadges: number maxBadges: number
maxResources: number maxResources: number
@@ -125,7 +126,6 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
maxStorylines: 0, maxStorylines: 0,
maxReversals: 0, maxReversals: 0,
maxChains: 0, maxChains: 0,
maxGallery: 0,
maxVoiceHighlights: 0, maxVoiceHighlights: 0,
maxBadges: 0, maxBadges: 0,
maxResources: 0, maxResources: 0,
@@ -153,7 +153,6 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
maxStorylines: 2, maxStorylines: 2,
maxReversals: 2, maxReversals: 2,
maxChains: 3, maxChains: 3,
maxGallery: 4,
maxVoiceHighlights: 2, maxVoiceHighlights: 2,
maxBadges: 3, maxBadges: 3,
maxResources: 4, maxResources: 4,
@@ -173,7 +172,6 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
'storylines', 'storylines',
'reversals', 'reversals',
'vision', 'vision',
'gallery',
'voices', 'voices',
'badges', 'badges',
'chains' 'chains'
@@ -185,9 +183,10 @@ export const buildGroupReportInput = async (
messages: Message[], messages: Message[],
contact: Contact | null, contact: Contact | null,
isGroup: boolean, isGroup: boolean,
reportMode: ReportMode reportMode: ReportMode,
options: BuildGroupReportFactsOptions = {}
): Promise<GroupReportInput> => { ): Promise<GroupReportInput> => {
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode) const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode, options)
const desiredTopicCount = const desiredTopicCount =
facts.metadata.messageCount >= 1000 facts.metadata.messageCount >= 1000
? '建议提炼 6-8 个互不重复的主要话题' ? '建议提炼 6-8 个互不重复的主要话题'
@@ -222,7 +221,8 @@ ${transcript}`
topSpeakers: facts.topSpeakers, topSpeakers: facts.topSpeakers,
activeTimeline: facts.activeTimeline, activeTimeline: facts.activeTimeline,
voiceLeaderboard: facts.voiceLeaderboard, voiceLeaderboard: facts.voiceLeaderboard,
media: facts.media media: facts.media,
imageInsightSummary: facts.imageInsightSummary
} }
} }
@@ -531,53 +531,6 @@ const topicLimitForMessageVolume = (
return config return config
} }
const attachHighImpactImage = (
topics: ReportTopic[],
gallery: GroupDailyReport['media']['gallery'],
visionGallery?: GroupDailyReport['media']['visionGallery']
): ReportTopic[] => {
if (!gallery.length && !visionGallery?.length) return topics
// 优先用 visionGallery(AI 真实识别的 description)
const firstVision = visionGallery?.find((it) => it.importance !== 'low')
const [firstImage, ...rest] = gallery
const nextTopics = topics.map((topic, index) => {
if (index !== 0) return topic
if (firstVision) {
// AI 真实识别路径:note 直接用 description,不带"根据推断"前缀
const noteParts = [firstVision.description]
if (firstVision.ocrText) noteParts.push(`文字:${firstVision.ocrText}`)
if (firstVision.tags.length) noteParts.push(`标签:${firstVision.tags.join('/')}`)
return {
...topic,
image: {
note: noteParts.join(' · '),
sourceMessageIds: firstVision.sourceMessageIds
// 注意:不填 imageUrl,因为 unknown imageHash 等问题可能导致 main 取不到原图,
// 让 renderer 在 buildGroupReportFacts 阶段就把 dataUrl 预先加载好塞到 gallery 里,
// 这里走 gallery 路径自然带 imageUrl
}
}
}
if (firstImage?.replyCount && firstImage.replyCount >= 3) {
return {
...topic,
image: {
imageUrl: firstImage.imageUrl,
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
sourceMessageIds: firstImage.sourceMessageIds
}
}
}
return topic
})
if (firstVision) {
// visionGallery 用过的不再展示
return nextTopics
}
gallery.splice(0, rest.length >= 0 ? 1 : 0)
return nextTopics
}
const postProcessReport = ( const postProcessReport = (
report: GroupDailyReport, report: GroupDailyReport,
mode: ReportMode, mode: ReportMode,
@@ -591,12 +544,7 @@ const postProcessReport = (
(item) => item.sourceMessageIds || [], (item) => item.sourceMessageIds || [],
(item) => createSignature(item.title, item.summary) (item) => createSignature(item.title, item.summary)
) )
const gallery = [...report.media.gallery] const topics = clampTopics(topicsDeduped, config)
const topics = attachHighImpactImage(
clampTopics(topicsDeduped, config),
gallery,
report.media.visionGallery
)
const importantMessagesRaw = sortByScore(report.importantMessages, (item) => const importantMessagesRaw = sortByScore(report.importantMessages, (item) =>
Math.max(item.importance || 0, item.confidence || 0.6) Math.max(item.importance || 0, item.confidence || 0.6)
@@ -784,13 +732,9 @@ const postProcessReport = (
0.72, 0.72,
0.9 0.9
), ),
gallery: buildSectionMeta( // gallery remains in the type for historical reports, but is disabled for
config.enabledSections.includes('gallery'), // every newly generated report because AI vision is the single image source.
gallery.length, gallery: buildSectionMeta(false, 0, 0, 0, 0),
report.media.gallery.length,
0.6,
0.8
),
voices: buildSectionMeta( voices: buildSectionMeta(
config.enabledSections.includes('voices'), config.enabledSections.includes('voices'),
voiceHighlights.length, voiceHighlights.length,
@@ -830,7 +774,7 @@ const postProcessReport = (
participantChains, participantChains,
keywords, keywords,
media: { media: {
gallery, gallery: [],
visionGallery: report.media.visionGallery, visionGallery: report.media.visionGallery,
voiceHighlights, voiceHighlights,
funBadges funBadges
@@ -0,0 +1,63 @@
import type {
GroupReportExportRequest,
GroupReportExportResult,
GroupReportRenderSnapshotExportRequest
} from '../../../shared/group-report'
import type {
GeneratedReportRecord,
PrepareGeneratedReportTemplateSwitchResult,
UpdateGeneratedReportTemplateRequest,
UpdateGeneratedReportTemplateResult
} from '../../../shared/report-history'
import type { SelectableReportTemplateId } from '../../../shared/report-templates'
interface ReportTemplateSwitchApi {
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
exportGroupReportSnapshot: (
request: GroupReportRenderSnapshotExportRequest
) => Promise<GroupReportExportResult>
prepareGeneratedReportTemplateSwitch: (
reportId: string
) => Promise<PrepareGeneratedReportTemplateSwitchResult>
updateGeneratedReportTemplate: (
request: UpdateGeneratedReportTemplateRequest
) => Promise<UpdateGeneratedReportTemplateResult>
}
export async function switchGeneratedReportTemplate(
report: GeneratedReportRecord,
templateId: SelectableReportTemplateId,
api: ReportTemplateSwitchApi
): Promise<UpdateGeneratedReportTemplateResult> {
let exported: GroupReportExportResult
if (report.reportSnapshot && report.reportMetadata) {
exported = await api.exportGroupReport({
report: report.reportSnapshot,
metadata: report.reportMetadata,
templateId
})
} else {
const prepared = await api.prepareGeneratedReportTemplateSwitch(report.id)
if (!prepared.success || !prepared.snapshot) {
return {
success: false,
error: prepared.error || '旧报告缺少可迁移的本地内容'
}
}
exported = await api.exportGroupReportSnapshot({
snapshot: prepared.snapshot,
templateId
})
}
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
return { success: false, error: exported.error || '新模板导出失败' }
}
return api.updateGeneratedReportTemplate({
reportId: report.id,
templateId,
generatedImage: exported.imageDataUrl,
htmlPath: exported.htmlPath,
pngPath: exported.pngPath
})
}
+19 -2
View File
@@ -1,6 +1,8 @@
export type ReportHeat = '高' | '中' | '低' export type ReportHeat = '高' | '中' | '低'
export type ReportMode = 'compact' | 'full' export type ReportMode = 'compact' | 'full'
import type { ReportTemplateRequestId } from './report-templates'
export const selectHeroParticipantNames = (names: string[]): string[] => export const selectHeroParticipantNames = (names: string[]): string[] =>
Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(0, 4) Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(0, 4)
@@ -283,8 +285,8 @@ export interface GroupReportMetadata {
export interface GroupReportExportRequest { export interface GroupReportExportRequest {
report: GroupDailyReport report: GroupDailyReport
metadata: GroupReportMetadata metadata: GroupReportMetadata
/** 模板 ID:'v1' 经典 / 'v2' 当前。缺省或未知值用默认(v2) */ /** v1 是默认经典模板,v2 仅保留旧调用兼容;另有五套新版产品模板。 */
templateId?: 'v1' | 'v2' templateId?: ReportTemplateRequestId
} }
export interface GroupReportExportResult { export interface GroupReportExportResult {
@@ -307,3 +309,18 @@ export interface GroupReportExportResult {
warnings?: string[] warnings?: string[]
error?: string error?: string
} }
/**
* GroupDailyReport HTML
* values / HTML
*/
export interface GroupReportRenderSnapshot {
groupName: string
reportDate: string
values: Record<string, string>
}
export interface GroupReportRenderSnapshotExportRequest {
snapshot: GroupReportRenderSnapshot
templateId: ReportTemplateRequestId
}
+19 -2
View File
@@ -94,7 +94,7 @@ export interface ImageCandidateQuery {
sessionId: string sessionId: string
startTime: number startTime: number
endTime: number endTime: number
/** 取 Top N,默认 3 */ /** 最多取 N 张,默认 3;服务端会将其限制在 0–3 */
limit?: number limit?: number
/** 由 renderer 从已加载消息中提取的图片候选(包含热度信息) */ /** 由 renderer 从已加载消息中提取的图片候选(包含热度信息) */
inputs?: Array<{ inputs?: Array<{
@@ -107,4 +107,21 @@ export interface ImageCandidateQuery {
responseCount: number responseCount: number
interactionCount: number interactionCount: number
}> }>
} }
/**
* A picture is considered hot only when it has observable follow-up activity.
* A bare image message (or a single passive reply) is not enough to enter the
* daily report's AI image selection. This keeps the "top 3" value an upper
* bound rather than a quota that fills every available slot.
*/
export const isHotImageCandidate = (input: {
responseCount: number
interactionCount: number
}): boolean =>
input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
export const calculateImageHeatScore = (input: {
responseCount: number
interactionCount: number
}): number => input.responseCount * 3 + input.interactionCount * 2 + 1
+35
View File
@@ -1,3 +1,10 @@
import type {
GroupDailyReport,
GroupReportMetadata,
GroupReportRenderSnapshot
} from './group-report'
import type { SelectableReportTemplateId } from './report-templates'
export type ReportAssetStatus = 'ready' | 'missing' export type ReportAssetStatus = 'ready' | 'missing'
export interface GeneratedReportRecord { export interface GeneratedReportRecord {
@@ -37,6 +44,11 @@ export interface GeneratedReportRecord {
endedAt: string endedAt: string
duration: number duration: number
}[] }[]
/** 新版报告保存结构化快照,模板切换时只重新渲染,不再调用 AI。 */
reportSnapshot?: GroupDailyReport
reportMetadata?: GroupReportMetadata
reportRenderSnapshot?: GroupReportRenderSnapshot
templateId?: SelectableReportTemplateId
} }
export interface SaveGeneratedReportRequest { export interface SaveGeneratedReportRequest {
@@ -63,6 +75,27 @@ export interface SaveGeneratedReportRequest {
endedAt: string endedAt: string
duration: number duration: number
}[] }[]
reportSnapshot?: GroupDailyReport
reportMetadata?: GroupReportMetadata
templateId?: SelectableReportTemplateId
}
export interface UpdateGeneratedReportTemplateRequest {
reportId: string
templateId: SelectableReportTemplateId
generatedImage?: string
htmlPath?: string
pngPath?: string
}
export interface PrepareGeneratedReportTemplateSwitchRequest {
reportId: string
}
export interface PrepareGeneratedReportTemplateSwitchResult {
success: boolean
snapshot?: GroupReportRenderSnapshot
error?: string
} }
export interface ReportHistoryResult { export interface ReportHistoryResult {
@@ -77,6 +110,8 @@ export interface SaveGeneratedReportResult {
error?: string error?: string
} }
export type UpdateGeneratedReportTemplateResult = SaveGeneratedReportResult
export interface DeleteGeneratedReportResult { export interface DeleteGeneratedReportResult {
success: boolean success: boolean
deletedId?: string deletedId?: string
+125
View File
@@ -0,0 +1,125 @@
export type ReportTemplateId =
| 'mobile-feed'
| 'mobile-magazine'
| 'mobile-dashboard'
| 'desktop-workspace'
| 'desktop-editorial'
export type LegacyReportTemplateId = 'v1' | 'v2'
export type SelectableReportTemplateId = 'v1' | ReportTemplateId
export type ReportTemplateRequestId = ReportTemplateId | LegacyReportTemplateId
export type ReportTemplatePlatform = 'default' | 'mobile' | 'desktop'
export interface ReportTemplateDefinition {
id: SelectableReportTemplateId
order: number
platform: ReportTemplatePlatform
label: string
name: string
tagline: string
fileLabel: string
cssClass: string
resourceFile: string
captureWidth: number
maxCaptureWidth: number
}
const SHARED_RESOURCE = 'daily_report_templates.html'
export const DEFAULT_REPORT_TEMPLATE: Readonly<ReportTemplateDefinition> = {
id: 'v1',
order: 0,
platform: 'default',
label: '默认模板',
name: '经典日报',
tagline: '熟悉的单栏日报版式,信息完整,适合直接生成与分享',
fileLabel: '经典版',
cssClass: 'template-classic',
resourceFile: 'mobile_daily_report_v1.html',
captureWidth: 430,
maxCaptureWidth: 1200
}
export const REPORT_TEMPLATES: readonly ReportTemplateDefinition[] = [
{
id: 'mobile-feed',
order: 1,
platform: 'mobile',
label: 'Mobile 01',
name: '微信信息流',
tagline: '聊天头像、真实消息与 AI 重点整理并列呈现',
fileLabel: '手机微信信息流',
cssClass: 'template-mobile-feed',
resourceFile: SHARED_RESOURCE,
captureWidth: 430,
maxCaptureWidth: 430
},
{
id: 'mobile-magazine',
order: 2,
platform: 'mobile',
label: 'Mobile 02',
name: 'AI Magazine',
tagline: '大标题、大数字与编辑式留白的科技日报',
fileLabel: '手机AI杂志',
cssClass: 'template-mobile-magazine',
resourceFile: SHARED_RESOURCE,
captureWidth: 430,
maxCaptureWidth: 430
},
{
id: 'mobile-dashboard',
order: 3,
platform: 'mobile',
label: 'Mobile 03',
name: 'AI Command Center',
tagline: 'KPI、热度、排行与问答构成的群聊数据驾驶舱',
fileLabel: '手机数据驾驶舱',
cssClass: 'template-mobile-dashboard',
resourceFile: SHARED_RESOURCE,
captureWidth: 430,
maxCaptureWidth: 430
},
{
id: 'desktop-workspace',
order: 4,
platform: 'desktop',
label: 'Desktop 01',
name: '三栏 AI 工作台',
tagline: '概览、核心讨论、成员与问答同时可见',
fileLabel: '桌面三栏工作台',
cssClass: 'template-desktop-workspace',
resourceFile: SHARED_RESOURCE,
captureWidth: 1440,
maxCaptureWidth: 1920
},
{
id: 'desktop-editorial',
order: 5,
platform: 'desktop',
label: 'Desktop 02',
name: 'Editorial 科技日报',
tagline: '头条、专栏、引语与新闻式分隔构成的编辑版面',
fileLabel: '桌面Editorial',
cssClass: 'template-desktop-editorial',
resourceFile: SHARED_RESOURCE,
captureWidth: 1440,
maxCaptureWidth: 1920
}
]
export const SELECTABLE_REPORT_TEMPLATES: readonly ReportTemplateDefinition[] = [
DEFAULT_REPORT_TEMPLATE,
...REPORT_TEMPLATES
]
export const isReportTemplateId = (value: unknown): value is ReportTemplateId =>
REPORT_TEMPLATES.some((template) => template.id === value)
export const isSelectableReportTemplateId = (
value: unknown
): value is SelectableReportTemplateId =>
SELECTABLE_REPORT_TEMPLATES.some((template) => template.id === value)
export const getReportTemplate = (value?: string): ReportTemplateDefinition =>
SELECTABLE_REPORT_TEMPLATES.find((template) => template.id === value) || DEFAULT_REPORT_TEMPLATE
+309 -1
View File
@@ -1,8 +1,28 @@
import { render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector' import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel' import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector'
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
import type { Contact } from '../../src/shared/types' import type { Contact } from '../../src/shared/types'
import type { GeneratedReportRecord } from '../../src/shared/report-history'
const noImageInsights = {
total: 0,
succeeded: 0,
failed: 0,
items: [],
failures: []
}
const currentModel = {
providerId: 'provider-1',
providerName: '默认服务',
model: 'model-1',
modelName: '默认模型',
configured: true,
status: 'connected' as const
}
const groupContact: Contact = { const groupContact: Contact = {
md5: 'group-md5', md5: 'group-md5',
@@ -18,6 +38,7 @@ describe('daily report controls', () => {
value: { value: {
getAppLogPath: vi.fn(async () => ''), getAppLogPath: vi.fn(async () => ''),
revealAppLog: vi.fn(async () => undefined), revealAppLog: vi.fn(async () => undefined),
listAIProviders: vi.fn(async () => ({ success: true, providers: [] })),
getGroupSnapshot: vi.fn(async () => ({ getGroupSnapshot: vi.fn(async () => ({
members: [ members: [
{ {
@@ -50,7 +71,13 @@ describe('daily report controls', () => {
error="" error=""
voiceTranscriptionProgress={progress} voiceTranscriptionProgress={progress}
voiceTranscriptionEnabled voiceTranscriptionEnabled
preparationProgress={null}
imageInsightSummary={noImageInsights}
canRetryModelStep={false}
currentModel={currentModel}
onRetry={vi.fn()} onRetry={vi.fn()}
onContinueAfterImageFailures={vi.fn()}
onCancelAfterImageFailures={vi.fn()}
/> />
) )
@@ -63,13 +90,270 @@ describe('daily report controls', () => {
error="" error=""
voiceTranscriptionProgress={null} voiceTranscriptionProgress={null}
voiceTranscriptionEnabled={false} voiceTranscriptionEnabled={false}
preparationProgress={null}
imageInsightSummary={noImageInsights}
canRetryModelStep={false}
currentModel={currentModel}
onRetry={vi.fn()} onRetry={vi.fn()}
onContinueAfterImageFailures={vi.fn()}
onCancelAfterImageFailures={vi.fn()}
/> />
) )
expect(screen.queryByText('转写语音消息')).not.toBeInTheDocument() expect(screen.queryByText('转写语音消息')).not.toBeInTheDocument()
expect(screen.getByText('2/4')).toBeVisible() expect(screen.getByText('2/4')).toBeVisible()
}) })
it('shows image insight results and pauses for confirmation when some images fail', () => {
const onContinue = vi.fn()
const onCancel = vi.fn()
render(
<ReportTaskStatusPanel
phase="awaitingImageDecision"
error=""
voiceTranscriptionProgress={null}
voiceTranscriptionEnabled={false}
preparationProgress={{
stage: 'summarizingInput',
label: '等待确认是否继续文字总结',
completed: 2,
total: 3
}}
imageInsightSummary={{
total: 3,
succeeded: 2,
failed: 1,
items: [
{
messageId: 'image-1',
sender: '成员一',
time: '10:20',
description: '一张表格型网页截图。',
ocrText: '列 A 列 B',
tags: ['表格', '网页']
}
],
failures: [
{
messageId: 'image-2',
sender: '成员二',
time: '10:21',
error: 'fetch failed'
}
]
}}
canRetryModelStep={false}
currentModel={currentModel}
onRetry={vi.fn()}
onContinueAfterImageFailures={onContinue}
onCancelAfterImageFailures={onCancel}
/>
)
expect(screen.getByText('等待确认')).toBeVisible()
expect(screen.getByText('有 1 张图片识别失败')).toBeVisible()
expect(screen.getByText('一张表格型网页截图。')).toBeVisible()
expect(screen.getByText('OCR:列 A 列 B')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '继续文字总结' }))
fireEvent.click(screen.getByRole('button', { name: '停止生成' }))
expect(onContinue).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('allows switching models and retrying only the model step', async () => {
const onRetry = vi.fn()
window.api.listAIProviders = vi.fn(async () => ({
success: true,
providers: [
{
id: 'provider-2',
name: '备用服务',
type: 'openai-compatible',
baseUrl: 'https://example.test',
auth: { type: 'bearer' },
models: [
{
id: 'model-2',
name: '备用模型',
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
}
],
defaultModel: 'model-2',
advanced: { timeoutMs: 120000, extraHeaders: {} },
hasApiKey: true,
isDefault: false,
status: 'connected'
}
]
}))
render(
<ReportTaskStatusPanel
phase="error"
error="fetch failed"
voiceTranscriptionProgress={null}
voiceTranscriptionEnabled={false}
preparationProgress={null}
imageInsightSummary={noImageInsights}
canRetryModelStep
currentModel={currentModel}
onRetry={onRetry}
onContinueAfterImageFailures={vi.fn()}
onCancelAfterImageFailures={vi.fn()}
/>
)
const retryButton = await screen.findByRole('button', { name: '使用所选模型重新生成' })
expect(screen.getByRole('option', { name: '备用服务 · 备用模型' })).toBeVisible()
fireEvent.click(retryButton)
expect(onRetry).toHaveBeenCalledWith(
expect.objectContaining({ providerId: 'provider-2', model: 'model-2' })
)
expect(screen.getByText(/从第三步继续/)).toBeVisible()
})
it('zooms relative to a full-image fit constrained by viewport width and height', () => {
const originalResizeObserver = globalThis.ResizeObserver
globalThis.ResizeObserver = class {
observe(): void {
return undefined
}
disconnect(): void {
return undefined
}
unobserve(): void {
return undefined
}
}
const report: GeneratedReportRecord = {
id: 'report-1',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-12T10:00:00.000Z',
reportDate: '2026-08-12',
htmlStatus: 'ready',
pngStatus: 'ready',
generatedImage: 'data:image/png;base64,fixture'
}
render(
<ReportViewer
report={report}
hasReports
onBackToConfigure={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn(async () => ({ success: true }))}
onReveal={vi.fn(async () => ({ success: true }))}
onSwitchTemplate={vi.fn(async () => ({ success: true }))}
/>
)
const image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1440 })
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 4000 })
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
configurable: true,
value: 760
})
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
configurable: true,
value: 600
})
fireEvent.load(image)
expect(image.style.width).toBe('200px')
fireEvent.click(screen.getByRole('button', { name: '缩小' }))
expect(image.style.width).toBe('160px')
fireEvent.click(screen.getByRole('button', { name: '放大' }))
expect(image.style.width).toBe('200px')
expect(screen.getByRole('button', { name: '完整显示' })).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '原始大小' }))
expect(image.style.width).toBe('1440px')
fireEvent.click(screen.getByRole('button', { name: '完整显示' }))
expect(image.style.width).toBe('200px')
globalThis.ResizeObserver = originalResizeObserver
})
it('switches templates from the top toolbar using the saved report snapshot', async () => {
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
const report: GeneratedReportRecord = {
id: 'report-switch',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-12T10:00:00.000Z',
reportDate: '2026-08-12',
htmlStatus: 'ready',
pngStatus: 'ready',
generatedImage: 'data:image/png;base64,fixture',
templateId: 'mobile-feed',
reportSnapshot: {} as GeneratedReportRecord['reportSnapshot'],
reportMetadata: {} as GeneratedReportRecord['reportMetadata']
}
const { rerender } = render(
<ReportViewer
report={report}
hasReports
onBackToConfigure={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn(async () => ({ success: true }))}
onReveal={vi.fn(async () => ({ success: true }))}
onSwitchTemplate={onSwitchTemplate}
/>
)
fireEvent.click(screen.getByRole('button', { name: '切换模板' }))
expect(screen.getByText('仅重新排版,不调用 AI')).toBeVisible()
expect(screen.getByRole('menuitem', { name: /默认模板经典日报/ })).toBeVisible()
fireEvent.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ }))
await waitFor(() => expect(onSwitchTemplate).toHaveBeenCalledWith(report, 'mobile-dashboard'))
rerender(
<ReportViewer
report={{
...report,
reportSnapshot: undefined,
reportMetadata: undefined,
htmlPath: '/tmp/legacy-report.html'
}}
hasReports
onBackToConfigure={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn(async () => ({ success: true }))}
onReveal={vi.fn(async () => ({ success: true }))}
onSwitchTemplate={onSwitchTemplate}
/>
)
expect(screen.getByRole('button', { name: '切换模板' })).toBeEnabled()
expect(screen.getByRole('button', { name: '切换模板' })).toHaveAttribute(
'title',
'使用已生成的数据或本地 HTML 更换展示模板,不会重新调用 AI'
)
rerender(
<ReportViewer
report={{
...report,
reportSnapshot: undefined,
reportMetadata: undefined,
reportRenderSnapshot: undefined,
htmlPath: undefined
}}
hasReports
onBackToConfigure={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn(async () => ({ success: true }))}
onReveal={vi.fn(async () => ({ success: true }))}
onSwitchTemplate={onSwitchTemplate}
/>
)
expect(screen.getByRole('button', { name: '切换模板' })).toBeDisabled()
expect(screen.getByRole('button', { name: '切换模板' })).toHaveAttribute(
'title',
'当前报告缺少可复用数据和 HTML,无法切换模板'
)
})
it('loads and displays group nickname, WeChat nickname, and remark separately', async () => { it('loads and displays group nickname, WeChat nickname, and remark separately', async () => {
render(<ReportGroupMemberSelector sourceContact={groupContact} />) render(<ReportGroupMemberSelector sourceContact={groupContact} />)
@@ -78,4 +362,28 @@ describe('daily report controls', () => {
expect(screen.getByText('通讯录备注一')).toBeVisible() expect(screen.getByText('通讯录备注一')).toBeVisible()
expect(screen.getByText('wxid-one')).toBeVisible() expect(screen.getByText('wxid-one')).toBeVisible()
}) })
it('offers the classic default plus three mobile and two desktop report templates', () => {
const onChange = vi.fn()
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
expect(screen.getAllByRole('radio')).toHaveLength(6)
expect(screen.getByText('默认模板', { selector: '.report-template-group-title' })).toBeVisible()
expect(screen.getByText('手机端 · 375414 px')).toBeVisible()
expect(screen.getByText('电脑端 · 12801920 px')).toBeVisible()
expect(screen.getByText('经典日报')).toBeVisible()
expect(screen.getByRole('radio', { name: /经典日报/ })).toBeChecked()
expect(screen.getByText('微信信息流')).toBeVisible()
expect(screen.getByText('AI Magazine')).toBeVisible()
expect(screen.getByText('AI Command Center')).toBeVisible()
expect(screen.getByText('三栏 AI 工作台')).toBeVisible()
expect(screen.getByText('Editorial 科技日报')).toBeVisible()
const previewButtons = screen.getAllByRole('button', { name: '查看版式' })
expect(previewButtons).toHaveLength(6)
fireEvent.click(previewButtons[2])
fireEvent.click(screen.getByRole('button', { name: '选择此模板' }))
expect(onChange).toHaveBeenCalledWith('mobile-magazine')
})
}) })
+19
View File
@@ -479,11 +479,30 @@ handle('report:export', () => {
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64')) fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
return { success: true, imageDataUrl: imageData, htmlPath, pngPath } return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
}) })
handle('report:exportSnapshot', () => {
const htmlPath = path.join(userData, 'fixture-report-snapshot.html')
const pngPath = path.join(userData, 'fixture-report-snapshot.png')
fs.writeFileSync(htmlPath, '<!doctype html><h1>固定脱敏模板快照日报</h1>', 'utf8')
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
})
handle('report:prepareTemplateSwitch', () => ({
success: true,
snapshot: {
groupName: '固定脱敏群',
reportDate: '2026-08-12',
values: { REPORT_TITLE: '固定脱敏群日报' }
}
}))
handle('report:listGenerated', () => ({ success: true, reports: [] })) handle('report:listGenerated', () => ({ success: true, reports: [] }))
handle('report:saveGenerated', (request) => ({ handle('report:saveGenerated', (request) => ({
success: true, success: true,
record: { id: 'fixture-report-record', ...request } record: { id: 'fixture-report-record', ...request }
})) }))
handle('report:updateGeneratedTemplate', (request) => ({
success: true,
record: { id: request.reportId, templateId: request.templateId }
}))
handle('report:deleteGenerated', () => ({ success: true })) handle('report:deleteGenerated', () => ({ success: true }))
handle('report:reveal', () => ({ success: true })) handle('report:reveal', () => ({ success: true }))
handle('copy-image', () => ({ success: true })) handle('copy-image', () => ({ success: true }))
+176 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
buildGroupReportInput, buildGroupReportInput,
parseGroupDailyReport parseGroupDailyReport
@@ -29,6 +29,16 @@ const media = {
funBadges: [] funBadges: []
} }
const previousWindow = (globalThis as { window?: unknown }).window
afterEach(() => {
if (previousWindow === undefined) {
Reflect.deleteProperty(globalThis, 'window')
} else {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow })
}
})
describe('group report parsing', () => { describe('group report parsing', () => {
it('keeps only distinct real participants in the hero avatar list', () => { it('keeps only distinct real participants in the hero avatar list', () => {
expect( expect(
@@ -113,6 +123,109 @@ describe('group report parsing', () => {
expect(input.prompt).toContain('微信系统消息:由于账号安全原因,无法加入当前群聊。') expect(input.prompt).toContain('微信系统消息:由于账号安全原因,无法加入当前群聊。')
}) })
it('injects successful image insights into the model prompt and reports partial failures', async () => {
const messages: Message[] = [
{
id: 'image-1',
from: 'member',
type: '图片',
datetime: '2026-08-12 10:00:00',
content: '[图片]',
name: '成员一',
isSender: false,
sessionId: 'group@chatroom',
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'one.dat' }
},
{
id: 'image-2',
from: 'member',
type: '图片',
datetime: '2026-08-12 10:01:00',
content: '[图片]',
name: '成员二',
isSender: false,
sessionId: 'group@chatroom',
contentData: { type: 'image', md5: 'b'.repeat(32), datName: 'two.dat' }
}
]
const progress = vi.fn()
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
api: {
imageListCandidates: vi.fn(async () => ({
success: true,
candidates: [
{
messageId: 'image-1',
imageHash: 'a'.repeat(32),
md5: 'a'.repeat(32),
datName: 'one.dat',
sessionId: 'group@chatroom',
sender: '成员一',
sentAt: new Date('2026-08-12 10:00:00').getTime(),
heatScore: 10
},
{
messageId: 'image-2',
imageHash: 'b'.repeat(32),
md5: 'b'.repeat(32),
datName: 'two.dat',
sessionId: 'group@chatroom',
sender: '成员二',
sentAt: new Date('2026-08-12 10:01:00').getTime(),
heatScore: 9
}
]
})),
getImage: vi.fn(async () => ({
success: true,
data: 'data:image/png;base64,fixture'
})),
imageAnalyze: vi
.fn()
.mockResolvedValueOnce({
success: true,
insight: {
id: 'insight-1',
messageId: 'image-1',
imageHash: 'a'.repeat(32),
description: '一张表格型网页截图,包含多列数据。',
ocrText: '项目 状态 负责人',
tags: ['表格', '管理界面'],
category: 'screenshot',
importance: 'medium',
provider: 'vision-provider',
model: 'vision-model',
createdAt: Date.now(),
updatedAt: Date.now(),
sender: '成员一',
sentAt: new Date('2026-08-12 10:00:00').getTime(),
sessionId: 'group@chatroom'
}
})
.mockResolvedValueOnce({ success: false, error: 'fetch failed' })
}
}
})
const input = await buildGroupReportInput(messages, null, true, 'full', {
onProgress: progress
})
expect(input.prompt).toContain('AI 图片识别摘要:')
expect(input.prompt).toContain('一张表格型网页截图,包含多列数据。')
expect(input.prompt).toContain('OCR: 项目 状态 负责人')
expect(input.imageInsightSummary).toMatchObject({ total: 2, succeeded: 1, failed: 1 })
expect(input.imageInsightSummary.failures[0]).toMatchObject({
messageId: 'image-2',
error: 'fetch failed'
})
expect(progress).toHaveBeenCalledWith(
expect.objectContaining({ stage: 'recognizingImages', completed: 2, total: 2 })
)
})
it('falls back to topic keywords when the model omits top-level keywords', () => { it('falls back to topic keywords when the model omits top-level keywords', () => {
const report = parseGroupDailyReport( const report = parseGroupDailyReport(
JSON.stringify({ JSON.stringify({
@@ -133,4 +246,66 @@ describe('group report parsing', () => {
expect(report.keywords).toEqual(['肌酸', '训练', '健身安排']) expect(report.keywords).toEqual(['肌酸', '训练', '健身安排'])
}) })
it('does not render the legacy gallery even when old report data contains it', () => {
const report = parseGroupDailyReport(
JSON.stringify({
topics: [{ title: '图片话题', summary: '围绕图片展开讨论。', keywords: ['图片'] }]
}),
[],
'',
[],
metadata,
{
gallery: [
{
sender: '成员一',
time: '10:00',
imageUrl: 'data:image/png;base64,fixture',
note: '旧相册数据'
}
],
voiceHighlights: [],
funBadges: []
}
)
expect(report.media.gallery).toEqual([])
expect(report.sectionMeta?.gallery).toMatchObject({ enabled: false, displayedCount: 0 })
})
it('allows a text report with zero AI images when no image reaches the hot threshold', async () => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
api: {
imageListCandidates: vi.fn(async () => ({ success: true, candidates: [] })),
getImage: vi.fn(),
imageAnalyze: vi.fn()
}
}
})
const input = await buildGroupReportInput(
[
{
id: 'cold-image',
from: 'member',
type: '图片',
datetime: '2026-08-12 10:00:00',
content: '[图片]',
name: '成员一',
isSender: false,
sessionId: 'group@chatroom',
contentData: { type: 'image', md5: 'c'.repeat(32), datName: 'cold.dat' }
}
],
null,
true,
'full'
)
expect(input.imageInsightSummary).toMatchObject({ total: 0, succeeded: 0, failed: 0 })
expect(input.media.gallery).toEqual([])
})
}) })
+88
View File
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getByHash } = vi.hoisted(() => ({ getByHash: vi.fn() }))
vi.mock('../../src/main/db/image-insights-store', () => ({
imageInsightsStore: {
getByHash,
upsert: vi.fn(),
listBySession: vi.fn()
}
}))
import { imageInsightService } from '../../src/main/services/image-insight-service'
const query = {
sessionId: 'group@chatroom',
startTime: 0,
endTime: Date.now(),
limit: 3
}
const input = (id: string, responseCount: number, interactionCount: number): {
messageId: string
md5: string
sessionId: string
sender: string
sentAt: number
responseCount: number
interactionCount: number
} => ({
messageId: id,
md5: id.repeat(32).slice(0, 32),
sessionId: 'group@chatroom',
sender: id,
sentAt: Date.now(),
responseCount,
interactionCount
})
describe('ImageInsightService hot image selection', () => {
beforeEach(() => {
getByHash.mockReset()
getByHash.mockReturnValue(null)
})
it('returns fewer than three images when only two pass the hot threshold', async () => {
const result = await imageInsightService.listTopHotImages(query, [
input('a', 3, 0),
input('b', 1, 1),
input('c', 1, 0),
input('d', 0, 5),
input('e', 0, 0)
])
expect(result.map((item) => item.messageId)).toEqual(['a', 'b'])
})
it('keeps the three highest-scoring hot images when more are eligible', async () => {
const result = await imageInsightService.listTopHotImages(query, [
input('a', 2, 0),
input('b', 5, 0),
input('c', 1, 1),
input('d', 3, 1)
])
expect(result.map((item) => item.messageId)).toEqual(['b', 'd', 'a'])
expect(result).toHaveLength(3)
})
it('returns no candidates when no image has meaningful follow-up activity', async () => {
const result = await imageInsightService.listTopHotImages(query, [
input('a', 1, 0),
input('b', 0, 4),
input('c', 0, 0)
])
expect(result).toEqual([])
})
it('keeps a cached insight attached to an eligible candidate', async () => {
const cached = { imageHash: 'a'.repeat(32), description: '缓存识别结果' }
getByHash.mockImplementation((hash: string) => (hash === 'a'.repeat(32) ? cached : null))
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
expect(result[0]).toMatchObject({ messageId: 'a', insight: cached })
})
})
+187
View File
@@ -0,0 +1,187 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { GroupDailyReport, GroupReportMetadata } from '../../src/shared/group-report'
const root = mkdtempSync(join(tmpdir(), 'tracememo-report-history-'))
vi.mock('electron', () => ({
app: { getPath: () => root },
nativeImage: {
createFromPath: () => ({
isEmpty: () => false,
getSize: () => ({ width: 430, height: 1200 })
})
}
}))
const reportSnapshot = {
overview: '已生成的结构化日报',
topics: [],
resources: [],
importantMessages: [],
quotes: [],
qa: [],
todos: [],
unresolved: [],
storylines: [],
reversals: [],
participantChains: [],
analytics: {
topicHeat: [],
activeTimeline: '',
topSpeakers: [],
voiceLeaderboard: []
},
keywords: [],
media: { gallery: [], visionGallery: [], voiceHighlights: [], funBadges: [] }
} satisfies GroupDailyReport
const reportMetadata = {
groupName: '测试群',
reportDate: '2026-08-12',
dateRange: '今天',
messageCount: 10,
activeUsers: 3,
timeSpan: '09:0018:00',
generatedAt: '2026-08-12T10:00:00.000Z',
recordNote: '',
footerNote: '',
heroParticipants: [],
avatars: {}
} satisfies GroupReportMetadata
describe('generated report template history', () => {
afterAll(() => {
rmSync(root, { recursive: true, force: true })
})
it('replaces the current report assets while preserving its structured snapshot and id', async () => {
const originalHtml = join(root, 'original.html')
const switchedHtml = join(root, 'switched.html')
writeFileSync(originalHtml, '<h1>Mobile 01</h1>')
writeFileSync(switchedHtml, '<h1>Mobile 03</h1>')
const { saveGeneratedReport, updateGeneratedReportTemplate } =
await import('../../src/main/report-history-service')
const saved = await saveGeneratedReport({
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-12T10:00:00.000Z',
generatedImage: `data:image/png;base64,${Buffer.from('mobile-01').toString('base64')}`,
htmlPath: originalHtml,
reportSnapshot,
reportMetadata,
templateId: 'mobile-feed'
})
expect(saved.success).toBe(true)
expect(saved.record).toBeDefined()
const updated = await updateGeneratedReportTemplate({
reportId: saved.record!.id,
templateId: 'mobile-dashboard',
generatedImage: `data:image/png;base64,${Buffer.from('mobile-03').toString('base64')}`,
htmlPath: switchedHtml
})
expect(updated.success).toBe(true)
expect(updated.record).toMatchObject({
id: saved.record!.id,
templateId: 'mobile-dashboard',
reportSnapshot,
reportMetadata
})
expect(readFileSync(updated.record!.htmlPath!, 'utf8')).toContain('Mobile 03')
expect(readFileSync(updated.record!.pngPath!).toString()).toBe('mobile-03')
expect(JSON.parse(readFileSync(updated.record!.jsonPath!, 'utf8'))).toMatchObject({
id: saved.record!.id,
templateId: 'mobile-dashboard',
reportSnapshot,
reportMetadata
})
})
it('keeps legacy records viewable but rejects a lossless template switch', async () => {
const legacyHtml = join(root, 'legacy.html')
writeFileSync(legacyHtml, '<h1>Legacy</h1>')
const { saveGeneratedReport, updateGeneratedReportTemplate } =
await import('../../src/main/report-history-service')
const saved = await saveGeneratedReport({
contactId: 'legacy-group',
contactName: '旧报告',
dateRange: '今天',
messageCount: 5,
generatedAt: '2026-08-12T11:00:00.000Z',
generatedImage: `data:image/png;base64,${Buffer.from('legacy').toString('base64')}`,
htmlPath: legacyHtml
})
const updated = await updateGeneratedReportTemplate({
reportId: saved.record!.id,
templateId: 'desktop-editorial',
generatedImage: `data:image/png;base64,${Buffer.from('new').toString('base64')}`,
htmlPath: legacyHtml
})
expect(updated).toEqual({
success: false,
error: '旧报告未保存结构化数据,无法无损切换模板'
})
})
it('extracts and persists a render snapshot once for legacy template switching', async () => {
const legacyHtml = join(root, 'legacy-with-html.html')
writeFileSync(legacyHtml, '<h1>Legacy source</h1>')
const { saveGeneratedReport, prepareGeneratedReportTemplateSwitch } =
await import('../../src/main/report-history-service')
const saved = await saveGeneratedReport({
contactId: 'legacy-snapshot-group',
contactName: '旧报告快照',
dateRange: '今天',
messageCount: 8,
generatedAt: '2026-08-12T12:00:00.000Z',
generatedImage: `data:image/png;base64,${Buffer.from('legacy-snapshot').toString('base64')}`,
htmlPath: legacyHtml
})
const extractSnapshot = vi.fn(async () => ({
groupName: '旧报告快照',
reportDate: '2026-08-12',
values: { REPORT_TITLE: '旧报告快照日报', TOPIC_CARDS: '<div>已有主题</div>' }
}))
const first = await prepareGeneratedReportTemplateSwitch(saved.record!.id, extractSnapshot)
const second = await prepareGeneratedReportTemplateSwitch(saved.record!.id, extractSnapshot)
expect(first).toEqual(second)
expect(extractSnapshot).toHaveBeenCalledTimes(1)
expect(JSON.parse(readFileSync(saved.record!.jsonPath!, 'utf8'))).toMatchObject({
id: saved.record!.id,
reportRenderSnapshot: first.snapshot
})
})
it('persists the classic v1 template as a selectable history template', async () => {
const classicHtml = join(root, 'classic.html')
writeFileSync(classicHtml, '<h1>经典日报</h1>')
const { saveGeneratedReport } = await import('../../src/main/report-history-service')
const saved = await saveGeneratedReport({
contactId: 'classic-group',
contactName: '经典日报群',
dateRange: '今天',
messageCount: 6,
generatedAt: '2026-08-12T13:00:00.000Z',
generatedImage: `data:image/png;base64,${Buffer.from('classic').toString('base64')}`,
htmlPath: classicHtml,
reportSnapshot,
reportMetadata,
templateId: 'v1'
})
expect(saved.success).toBe(true)
expect(saved.record?.templateId).toBe('v1')
expect(JSON.parse(readFileSync(saved.record!.jsonPath!, 'utf8')).templateId).toBe('v1')
})
})
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from 'vitest'
import type { GeneratedReportRecord } from '../../src/shared/report-history'
import { switchGeneratedReportTemplate } from '../../src/renderer/src/utils/report-template-switch'
const structuredReport = {
id: 'report-1',
contactId: 'group-1',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-12T10:00:00.000Z',
reportDate: '2026-08-12',
htmlStatus: 'ready',
pngStatus: 'ready',
templateId: 'mobile-feed',
reportSnapshot: { overview: '已有内容' },
reportMetadata: { groupName: '测试群' }
} as GeneratedReportRecord
describe('report template switching pipeline', () => {
it('only exports the saved snapshot and updates the same history record', async () => {
const api = {
exportGroupReport: vi.fn(async () => ({
success: true,
imageDataUrl: 'data:image/png;base64,new',
htmlPath: '/tmp/new.html',
pngPath: '/tmp/new.png'
})),
exportGroupReportSnapshot: vi.fn(),
prepareGeneratedReportTemplateSwitch: vi.fn(),
updateGeneratedReportTemplate: vi.fn(async () => ({
success: true,
record: { ...structuredReport, templateId: 'mobile-dashboard' as const }
}))
}
const result = await switchGeneratedReportTemplate(structuredReport, 'mobile-dashboard', api)
expect(result.success).toBe(true)
expect(api.exportGroupReport).toHaveBeenCalledTimes(1)
expect(api.exportGroupReport).toHaveBeenCalledWith({
report: structuredReport.reportSnapshot,
metadata: structuredReport.reportMetadata,
templateId: 'mobile-dashboard'
})
expect(api.updateGeneratedReportTemplate).toHaveBeenCalledWith({
reportId: structuredReport.id,
templateId: 'mobile-dashboard',
generatedImage: 'data:image/png;base64,new',
htmlPath: '/tmp/new.html',
pngPath: '/tmp/new.png'
})
})
it('switches an existing report back to the classic default without rerunning AI', async () => {
const api = {
exportGroupReport: vi.fn(async () => ({
success: true,
imageDataUrl: 'data:image/png;base64,classic',
htmlPath: '/tmp/classic.html',
pngPath: '/tmp/classic.png'
})),
exportGroupReportSnapshot: vi.fn(),
prepareGeneratedReportTemplateSwitch: vi.fn(),
updateGeneratedReportTemplate: vi.fn(async () => ({
success: true,
record: { ...structuredReport, templateId: 'v1' as const }
}))
}
const result = await switchGeneratedReportTemplate(structuredReport, 'v1', api)
expect(result.success).toBe(true)
expect(api.exportGroupReport).toHaveBeenCalledWith({
report: structuredReport.reportSnapshot,
metadata: structuredReport.reportMetadata,
templateId: 'v1'
})
expect(api.prepareGeneratedReportTemplateSwitch).not.toHaveBeenCalled()
expect(api.updateGeneratedReportTemplate).toHaveBeenCalledWith(
expect.objectContaining({ reportId: structuredReport.id, templateId: 'v1' })
)
})
it('migrates a legacy record from its saved HTML before switching', async () => {
const api = {
exportGroupReport: vi.fn(),
exportGroupReportSnapshot: vi.fn(async () => ({
success: true,
imageDataUrl: 'data:image/png;base64,legacy-new',
htmlPath: '/tmp/legacy-new.html',
pngPath: '/tmp/legacy-new.png'
})),
prepareGeneratedReportTemplateSwitch: vi.fn(async () => ({
success: true,
snapshot: {
groupName: '测试群',
reportDate: '2026-08-12',
values: { REPORT_TITLE: '测试群日报' }
}
})),
updateGeneratedReportTemplate: vi.fn(async () => ({ success: true }))
}
const result = await switchGeneratedReportTemplate(
{ ...structuredReport, reportSnapshot: undefined, reportMetadata: undefined },
'desktop-editorial',
api
)
expect(result.success).toBe(true)
expect(api.exportGroupReport).not.toHaveBeenCalled()
expect(api.prepareGeneratedReportTemplateSwitch).toHaveBeenCalledWith('report-1')
expect(api.exportGroupReportSnapshot).toHaveBeenCalledWith({
snapshot: expect.objectContaining({ groupName: '测试群' }),
templateId: 'desktop-editorial'
})
})
it('does not call any pipeline step when a legacy record cannot be migrated', async () => {
const api = {
exportGroupReport: vi.fn(),
exportGroupReportSnapshot: vi.fn(),
prepareGeneratedReportTemplateSwitch: vi.fn(async () => ({
success: false,
error: '当前日报缺少 HTML 文件,无法迁移旧模板数据'
})),
updateGeneratedReportTemplate: vi.fn()
}
const result = await switchGeneratedReportTemplate(
{ ...structuredReport, reportSnapshot: undefined, reportMetadata: undefined },
'desktop-editorial',
api
)
expect(result).toEqual({ success: false, error: '当前日报缺少 HTML 文件,无法迁移旧模板数据' })
expect(api.exportGroupReport).not.toHaveBeenCalled()
expect(api.updateGeneratedReportTemplate).not.toHaveBeenCalled()
})
})
+70
View File
@@ -0,0 +1,70 @@
import { existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_REPORT_TEMPLATE,
getReportTemplate,
isReportTemplateId,
isSelectableReportTemplateId,
REPORT_TEMPLATES,
SELECTABLE_REPORT_TEMPLATES
} from '../../src/shared/report-templates'
describe('daily report templates', () => {
it('exposes the classic default plus exactly three mobile and two desktop product templates', () => {
expect(REPORT_TEMPLATES).toHaveLength(5)
expect(SELECTABLE_REPORT_TEMPLATES).toHaveLength(6)
expect(SELECTABLE_REPORT_TEMPLATES[0]).toEqual(DEFAULT_REPORT_TEMPLATE)
expect(DEFAULT_REPORT_TEMPLATE).toMatchObject({
id: 'v1',
label: '默认模板',
name: '经典日报',
resourceFile: 'mobile_daily_report_v1.html'
})
expect(REPORT_TEMPLATES.filter((template) => template.platform === 'mobile')).toHaveLength(3)
expect(REPORT_TEMPLATES.filter((template) => template.platform === 'desktop')).toHaveLength(2)
expect(new Set(REPORT_TEMPLATES.map((template) => template.cssClass)).size).toBe(5)
})
it('uses mobile and desktop capture widths appropriate to their layouts', () => {
expect(DEFAULT_REPORT_TEMPLATE.captureWidth).toBe(430)
expect(DEFAULT_REPORT_TEMPLATE.maxCaptureWidth).toBeGreaterThanOrEqual(
DEFAULT_REPORT_TEMPLATE.captureWidth
)
for (const template of REPORT_TEMPLATES) {
expect(
template.platform === 'mobile' ? template.captureWidth : template.captureWidth >= 1280
).toBeTruthy()
expect(template.maxCaptureWidth).toBeGreaterThanOrEqual(template.captureWidth)
}
})
it('resolves v1 and unknown ids to the classic default template', () => {
expect(isReportTemplateId('desktop-editorial')).toBe(true)
expect(isReportTemplateId('v1')).toBe(false)
expect(isSelectableReportTemplateId('v1')).toBe(true)
expect(isSelectableReportTemplateId('v2')).toBe(false)
expect(getReportTemplate('unknown').id).toBe('v1')
expect(getReportTemplate('v1')).toEqual(DEFAULT_REPORT_TEMPLATE)
})
it('ships the classic resource and shared semantic resource with five distinct layout classes', () => {
expect(existsSync(resolve('resources', DEFAULT_REPORT_TEMPLATE.resourceFile))).toBe(true)
const resourcePath = resolve('resources', 'daily_report_templates.html')
expect(existsSync(resourcePath)).toBe(true)
const html = readFileSync(resourcePath, 'utf8')
for (const template of REPORT_TEMPLATES) {
expect(html).toContain(`.${template.cssClass}`)
}
expect(html).toContain('{{TOPIC_CARDS}}')
expect(html).toContain('{{IMPORTANT_MESSAGES}}')
expect(html).toContain('{{QA_CARDS}}')
expect(html).toContain('{{RANK_ITEMS}}')
expect(html).toContain('{{HERO_AVATARS}}')
expect(html).not.toContain('群聊相册')
expect(html).not.toContain('今日群相册')
expect(html).toContain('grid-template-columns: minmax(78px, 104px) minmax(72px, 1fr) 34px')
expect(html).toContain('.template-mobile-dashboard .heat-name {')
expect(html).toContain('white-space: normal')
})
})