From d439b4b7492a9a0b3728d495f294eeb769cb4683 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Wed, 12 Aug 2026 16:10:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=97=A5=E6=8A=A5?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/daily_report_templates.html | 1491 +++++++++++++++++ resources/mobile_daily_report.html | 32 - resources/mobile_daily_report_v2.html | 32 - scripts/generate-report-template-preview.cjs | 244 +++ scripts/render-report-fixtures.mjs | 24 +- src/main/group-report-service.ts | 326 +++- src/main/index.ts | 38 +- src/main/report-history-service.ts | 128 +- src/main/services/image-insight-service.ts | 19 +- src/preload/index.d.ts | 20 +- src/preload/index.ts | 16 +- src/renderer/src/App.tsx | 52 +- .../components/reports/AiReportWorkspace.tsx | 6 +- .../reports/ReportTaskStatusPanel.tsx | 213 ++- .../reports/ReportTemplateSelector.tsx | 209 ++- .../src/components/reports/ReportToolbar.tsx | 54 +- .../src/components/reports/ReportViewer.tsx | 118 +- .../src/components/reports/ReportZoomBar.tsx | 21 +- src/renderer/src/components/reports/types.ts | 4 +- .../src/hooks/useGroupReportGeneration.ts | 509 ++++-- src/renderer/src/styles/reports-core.scss | 160 ++ .../src/styles/reports-templates.scss | 360 +++- src/renderer/src/utils/group-report-facts.ts | 216 ++- src/renderer/src/utils/group-report.ts | 80 +- .../src/utils/report-template-switch.ts | 63 + src/shared/group-report.ts | 21 +- src/shared/image-insight.ts | 21 +- src/shared/report-history.ts | 35 + src/shared/report-templates.ts | 125 ++ tests/component/report-features.test.tsx | 310 +++- tests/e2e/support/electron-main.cjs | 19 + tests/unit/group-report.test.ts | 177 +- tests/unit/image-insight-service.test.ts | 88 + tests/unit/report-history-service.test.ts | 187 +++ tests/unit/report-template-switch.test.ts | 139 ++ tests/unit/report-templates.test.ts | 70 + 36 files changed, 5068 insertions(+), 559 deletions(-) create mode 100644 resources/daily_report_templates.html create mode 100644 scripts/generate-report-template-preview.cjs create mode 100644 src/renderer/src/utils/report-template-switch.ts create mode 100644 src/shared/report-templates.ts create mode 100644 tests/unit/image-insight-service.test.ts create mode 100644 tests/unit/report-history-service.test.ts create mode 100644 tests/unit/report-template-switch.test.ts create mode 100644 tests/unit/report-templates.test.ts diff --git a/resources/daily_report_templates.html b/resources/daily_report_templates.html new file mode 100644 index 0000000..8acb4db --- /dev/null +++ b/resources/daily_report_templates.html @@ -0,0 +1,1491 @@ + + + + + + {{REPORT_TITLE}} + + + +
+
+
+
{{TEMPLATE_LABEL}} · {{TEMPLATE_NAME}}
+

{{REPORT_TITLE}}

+
+
+ {{REPORT_DATE}}
{{DATE_RANGE}}
{{TIME_SPAN}} +
+
+

{{HERO_SUMMARY}}

+
{{HERO_AVATARS}}
+
+
{{HERO_STATUS_LINE}}
+
{{HERO_TAKEAWAY}}
+
{{HERO_PENDING}}
+
+
+
{{MESSAGE_COUNT}}Messages
+
{{ACTIVE_USERS}}Active users
+
{{TIME_SPAN}}Discussion window
+
{{TOPIC_COUNT}}Topics
+
+ +
+ + +
+
+
+

今日讨论热点

+ Top discussions +
+
{{TOPIC_CARDS}}
+ {{TOPICS_MORE_NOTE}} +
+
+
+

真实消息摘录

+ Selected messages +
+
{{QUOTE_BLOCKS}}
+ {{QUOTES_MORE_NOTE}} +
+
+
+

资源与链接

+ Resources +
+
{{RESOURCE_ITEMS}}
+ {{RESOURCES_MORE_NOTE}} +
+
+ + +
+ +
+
+
+
+

今日剧情

+ Timeline +
+
{{STORYLINE_CARDS}}
+
+
+
+

观点反转

+ Reversals +
+
{{REVERSAL_CARDS}}
+
+
+
+

参与链路

+ Participants +
+
{{CHAIN_CARDS}}
+
+
+
+
+
+

{{VISION_TITLE}}

+ Vision +
+
{{VISION_CARDS}}
+
+
+
+

语音亮点

+ Voice +
+
{{VOICE_CARDS}}
+ {{VOICE_MORE_NOTE}} +
+
+
+

语音时长榜

+ Voice rank +
+
{{VOICE_RANK_CARDS}}
+
+
+
+

今日临时人设

+ Badges +
+
{{BADGE_CARDS}}
+ {{BADGES_MORE_NOTE}} +
+
+
+ +
+ {{FOOTER_NOTE}}
{{RECORD_NOTE}}
Generated {{GENERATED_AT}} +
+
+ + diff --git a/resources/mobile_daily_report.html b/resources/mobile_daily_report.html index 9962a6c..46cc63e 100644 --- a/resources/mobile_daily_report.html +++ b/resources/mobile_daily_report.html @@ -464,32 +464,6 @@ font-style: normal; color: #98a2b3; } - .gallery-card { - display: grid; - grid-template-columns: 112px 1fr; - gap: 12px; - margin-top: 10px; - padding: 12px; - background: #f7faf9; - border-radius: 14px; - } - .gallery-image { - width: 112px; - height: 112px; - border-radius: 12px; - object-fit: cover; - background: #e5e7eb; - } - .gallery-stats { - display: inline-flex; - margin-top: 7px; - padding: 4px 8px; - border-radius: 999px; - background: #eef5ff; - color: #1677ff; - font-size: 11px; - font-weight: 700; - } .badge-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -644,7 +618,6 @@ padding: 11px; } .compact .important-card, - .compact .gallery-card, .compact .chat-block { padding: 10px; } @@ -801,11 +774,6 @@ {{VISION_CARDS}} -
-
今日群相册
- {{GALLERY_CARDS}} - {{GALLERY_MORE_NOTE}} -
语音之最
diff --git a/resources/mobile_daily_report_v2.html b/resources/mobile_daily_report_v2.html index b41998a..dc85de3 100644 --- a/resources/mobile_daily_report_v2.html +++ b/resources/mobile_daily_report_v2.html @@ -465,32 +465,6 @@ font-style: normal; color: #98a2b3; } - .gallery-card { - display: grid; - grid-template-columns: 112px 1fr; - gap: 12px; - margin-top: 10px; - padding: 12px; - background: #f7faf9; - border-radius: 14px; - } - .gallery-image { - width: 112px; - height: 112px; - border-radius: 12px; - object-fit: cover; - background: #e5e7eb; - } - .gallery-stats { - display: inline-flex; - margin-top: 7px; - padding: 4px 8px; - border-radius: 999px; - background: #eef5ff; - color: #1677ff; - font-size: 11px; - font-weight: 700; - } .badge-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -645,7 +619,6 @@ padding: 11px; } .compact .important-card, - .compact .gallery-card, .compact .chat-block { padding: 10px; } @@ -801,11 +774,6 @@ {{VISION_CARDS}}
-
-
今日群相册
- {{GALLERY_CARDS}} - {{GALLERY_MORE_NOTE}} -
语音之最
diff --git a/scripts/generate-report-template-preview.cjs b/scripts/generate-report-template-preview.cjs new file mode 100644 index 0000000..5d49906 --- /dev/null +++ b/scripts/generate-report-template-preview.cjs @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +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) => ` + ` + ) + .join('') + +const indexHtml = ` + + + + + ${escapeHtml(reportName)} · 五套日报模板预览 + + + +

${escapeHtml(reportName)} · 五套日报模板

同一份 2026-08-11 真实日报数据,可直接切换比较布局、排版和信息密度。

${buttons}
+
+ + +` + +fs.writeFileSync(path.join(outputDir, 'index.html'), indexHtml, 'utf8') +console.log(path.join(outputDir, 'index.html')) diff --git a/scripts/render-report-fixtures.mjs b/scripts/render-report-fixtures.mjs index 2f2dd5b..f33368a 100644 --- a/scripts/render-report-fixtures.mjs +++ b/scripts/render-report-fixtures.mjs @@ -182,7 +182,6 @@ fullRequest.report.sectionMeta = { qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 }, storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 }, reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 }, - gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 }, voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 }, badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 }, chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 } @@ -218,24 +217,7 @@ fullRequest.report.reversals = [ { topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' } ] fullRequest.report.media = { - gallery: [ - { - sender: '阿宇', - time: '09:52', - imageUrl: sampleImage, - note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。', - stats: '12 条后续消息 · 6 人接话', - inferenceLabel: '基于图片后的聊天上下文推断' - }, - { - sender: '佩佩', - time: '17:14', - imageUrl: sampleImage, - note: '第二张图带起一轮轻松但有效的快速确认。', - stats: '5 条后续消息 · 3 人接话', - inferenceLabel: '基于图片后的聊天上下文推断' - } - ], + gallery: [], voiceHighlights: [ { title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' }, { title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' } @@ -281,7 +263,6 @@ async function renderRequest(request, targetBase) { const qaCards = (report.qa || []).map((item) => `
Q:${escapeHtml(item.question)}
A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
`).join('') const storylineCards = (report.storylines || []).map((item) => `

${escapeHtml(item.title)}

${item.stages.map((stage) => `
${escapeHtml(stage.time)}${escapeHtml(stage.event)}
`).join('')}
${item.result ? `

${escapeHtml(item.result)}

` : ''}
`).join('') const reversalCards = (report.reversals || []).map((item) => `
${escapeHtml(item.topic)}
最初:${escapeHtml(item.initialView)}
后来:${escapeHtml(item.finalView)}
${item.note ? `
${escapeHtml(item.note)}
` : ''}
`).join('') - const galleryCards = (report.media.gallery || []).map((item) => ``).join('') const voiceCards = (report.media.voiceHighlights || []).map((item) => `
${escapeHtml(item.title)} · ${escapeHtml(item.sender)}
${escapeHtml(item.note)}
`).join('') const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `
${index + 1}. ${escapeHtml(item.sender)}${item.count} 条 · ${item.durationSec} 秒
`).join('') const badgeCards = (report.media.funBadges || []).map((item) => `
${escapeHtml(item.title)}${escapeHtml(item.owner)}

${escapeHtml(item.note)}

`).join('') @@ -343,9 +324,6 @@ async function renderRequest(request, targetBase) { REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section', REVERSAL_CARDS: reversalCards, REVERSALS_MORE_NOTE: '', - GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section', - GALLERY_CARDS: galleryCards, - GALLERY_MORE_NOTE: '', VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section', VOICE_CARDS: voiceCards, VOICE_MORE_NOTE: '', diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts index d4452c8..497f6a3 100644 --- a/src/main/group-report-service.ts +++ b/src/main/group-report-service.ts @@ -6,21 +6,23 @@ import { GroupReportExportRequest, GroupReportExportResult, GroupReportMetadata, + GroupReportRenderSnapshot, + GroupReportRenderSnapshotExportRequest, ReportHeat, ReportSectionMeta, selectHeroParticipantNames } from '../shared/group-report' import { resolveMd5, getGroupSnapshot } from './services/chat-service' import { imageInsightService } from './services/image-insight-service' +import { getReportTemplate } from '../shared/report-templates' -const TEMPLATE_FILES: Record = { +const LEGACY_TEMPLATE_FILES: Record = { v1: 'mobile_daily_report_v1.html', v2: 'mobile_daily_report_v2.html' } -const DEFAULT_TEMPLATE = TEMPLATE_FILES.v1 const templatePath = (templateId?: string): string => { - const name = TEMPLATE_FILES[templateId || ''] || DEFAULT_TEMPLATE + const name = LEGACY_TEMPLATE_FILES[templateId || ''] || getReportTemplate(templateId).resourceFile const candidates = [ path.join(process.resourcesPath, 'resources', name), path.join(app.getAppPath(), 'resources', name), @@ -168,6 +170,7 @@ const overflowNote = ( const renderReportHtml = async (request: GroupReportExportRequest): Promise => { const { report, metadata } = request + const template = getReportTemplate(request.templateId) const avatarNames = new Set(metadata.heroParticipants) report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name))) report.importantMessages.forEach((message) => avatarNames.add(message.sender)) @@ -175,7 +178,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise avatarNames.add(message.sender)) ) report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name)) - report.media?.gallery?.forEach((item) => avatarNames.add(item.sender)) report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender)) report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner)) @@ -341,20 +343,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise `` - ) - .join('') - // AI 图片理解结果板块(ImageInsight) // 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文 const visionCards = (report.media?.visionGallery || []) @@ -411,11 +399,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise Math.max(0, speaker.count))) + const heatBarsHtml = heatSpeakers .map((speaker) => { 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 `
${escapeHtml(speaker.name)} @@ -454,7 +443,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise = { + TEMPLATE_CLASS: template.cssClass, + TEMPLATE_LABEL: escapeHtml(template.label), + TEMPLATE_NAME: escapeHtml(template.name), REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`), + REPORT_DATE: escapeHtml(metadata.reportDate), REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact', GROUP_NAME: escapeHtml(metadata.groupName), DATE_RANGE: escapeHtml(metadata.dateRange), @@ -525,9 +518,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise 0), - GALLERY_CARDS: galleryCards, - GALLERY_MORE_NOTE: overflowNote(request, 'gallery'), VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0), VOICE_CARDS: voiceCards, VOICE_MORE_NOTE: overflowNote(request, 'voices'), @@ -560,11 +550,242 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise => { +const renderReportSnapshotHtml = async ( + request: GroupReportRenderSnapshotExportRequest +): Promise => { + 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 => { + 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 + } + if (!extracted.reportTitle || !extracted.topicCards) { + throw new Error('旧日报 HTML 缺少可迁移的标题或主题内容') + } + + const values: Record = { + 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 => { + 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}`) const reportWindow = new BrowserWindow({ show: false, - width: 430, + width: captureWidth, height: 800, frame: false, backgroundColor: '#f3f5f7', @@ -583,10 +804,10 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise setTimeout(resolve, 100)) @@ -611,7 +832,12 @@ export const exportGroupReport = async ( const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录') 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 htmlPath = path.join(outputDir, `${baseName}.html`) const pngPath = path.join(outputDir, `${baseName}.png`) @@ -620,7 +846,7 @@ export const exportGroupReport = async ( await fs.writeFile(htmlPath, html, 'utf8') const htmlEndedAt = new Date() const pngStartedAt = new Date() - const imageDataUrl = await captureFullPage(htmlPath, pngPath) + const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId) const pngEndedAt = new Date() return { success: true, @@ -646,3 +872,43 @@ export const exportGroupReport = async ( return { success: false, error: error instanceof Error ? error.message : String(error) } } } + +export const exportGroupReportSnapshot = async ( + request: GroupReportRenderSnapshotExportRequest +): Promise => { + 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) } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index ce34ee4..7922484 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,14 +32,27 @@ import { inspectImageDecoderStatus, type DecodedImage } from './image-decrypt-service' -import { exportGroupReport } from './group-report-service' +import { + exportGroupReport, + exportGroupReportSnapshot, + extractGroupReportRenderSnapshot +} from './group-report-service' import { deleteGeneratedReport, listGeneratedReports, - saveGeneratedReport + prepareGeneratedReportTemplateSwitch, + saveGeneratedReport, + updateGeneratedReportTemplate } from './report-history-service' -import type { GroupReportExportRequest } from '../shared/group-report' -import type { SaveGeneratedReportRequest } from '../shared/report-history' +import type { + GroupReportExportRequest, + GroupReportRenderSnapshotExportRequest +} from '../shared/group-report' +import type { + SaveGeneratedReportRequest, + PrepareGeneratedReportTemplateSwitchRequest, + UpdateGeneratedReportTemplateRequest +} from '../shared/report-history' import type { AIChatRequestOptions, AiSearchExternalAuthorizationRequest, @@ -1132,6 +1145,10 @@ app.whenReady().then(async () => { ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => { return exportGroupReport(request) }) + ipcMain.handle( + 'report:exportSnapshot', + async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request) + ) ipcMain.handle('export:start', async (event, request: ExportRequest) => { const window = BrowserWindow.fromWebContents(event.sender) @@ -1158,6 +1175,19 @@ app.whenReady().then(async () => { 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) => { return deleteGeneratedReport(reportId) }) diff --git a/src/main/report-history-service.ts b/src/main/report-history-service.ts index 7753ffc..bcfe2a6 100644 --- a/src/main/report-history-service.ts +++ b/src/main/report-history-service.ts @@ -7,8 +7,11 @@ import type { ReportAssetStatus, ReportHistoryResult, SaveGeneratedReportRequest, - SaveGeneratedReportResult + SaveGeneratedReportResult, + UpdateGeneratedReportTemplateRequest, + UpdateGeneratedReportTemplateResult } from '../shared/report-history' +import type { GroupReportRenderSnapshot } from '../shared/group-report' const REPORTS_DIR = 'reports' @@ -184,7 +187,10 @@ export async function saveGeneratedReport( html: await readFileSize(savedHtmlPath), 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') @@ -200,6 +206,124 @@ export async function saveGeneratedReport( } } +export async function updateGeneratedReportTemplate( + request: UpdateGeneratedReportTemplateRequest +): Promise { + 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 +): Promise { + 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( reportId: string ): Promise { diff --git a/src/main/services/image-insight-service.ts b/src/main/services/image-insight-service.ts index 70b331c..64dcdaf 100644 --- a/src/main/services/image-insight-service.ts +++ b/src/main/services/image-insight-service.ts @@ -5,7 +5,7 @@ // 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果) // 2. 同图(imageHash)走缓存,绝不重复调 AI // 3. 失败不抛,日志记录 + 返回原状(不阻塞日报) -// 4. 第一阶段:Top 3 热点图 + 缓存命中即返回,未命中并发调 AI +// 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI import crypto from 'crypto' import { randomUUID } from 'crypto' @@ -22,6 +22,10 @@ import type { ImageCandidateQuery, ImageInsight } from '../../shared/image-insight' +import { + calculateImageHeatScore, + isHotImageCandidate +} from '../../shared/image-insight' /** * 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。 @@ -234,7 +238,7 @@ class ImageInsightService { query: ImageCandidateQuery, inputs: ImageCandidateInput[] = [] ): Promise { - const limit = query.limit ?? 3 + const limit = Math.min(3, Math.max(0, query.limit ?? 3)) const candidates: ImageCandidate[] = [] console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length) for (const input of inputs) { @@ -248,7 +252,16 @@ class ImageInsightService { ) 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 = { messageId: input.messageId, imageHash: hash, diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a306495..a42bdbd 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,12 +1,19 @@ import { ElectronAPI } from '@electron-toolkit/preload' 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 { DeleteGeneratedReportResult, ReportHistoryResult, SaveGeneratedReportRequest, - SaveGeneratedReportResult + SaveGeneratedReportResult, + PrepareGeneratedReportTemplateSwitchResult, + UpdateGeneratedReportTemplateRequest, + UpdateGeneratedReportTemplateResult } from '../shared/report-history' import type { DatabaseKeyEnvironment, @@ -305,10 +312,19 @@ declare global { revealExport: (path: string) => Promise<{ success: boolean; error?: string }> onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void exportGroupReport: (request: GroupReportExportRequest) => Promise + exportGroupReportSnapshot: ( + request: GroupReportRenderSnapshotExportRequest + ) => Promise + prepareGeneratedReportTemplateSwitch: ( + reportId: string + ) => Promise listGeneratedReports: () => Promise saveGeneratedReport: ( request: SaveGeneratedReportRequest ) => Promise + updateGeneratedReportTemplate: ( + request: UpdateGeneratedReportTemplateRequest + ) => Promise deleteGeneratedReport: (reportId: string) => Promise revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> getSavedDbKey: (accountRoot: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 5be1820..e645036 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,13 @@ import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' -import type { GroupReportExportRequest } from '../shared/group-report' -import type { SaveGeneratedReportRequest } from '../shared/report-history' +import type { + GroupReportExportRequest, + GroupReportRenderSnapshotExportRequest +} from '../shared/group-report' +import type { + SaveGeneratedReportRequest, + UpdateGeneratedReportTemplateRequest +} from '../shared/report-history' import type { AIChatRequestOptions, AiSearchExternalAuthorizationRequest, @@ -206,9 +212,15 @@ const api = { }, exportGroupReport: (request: GroupReportExportRequest) => 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'), saveGeneratedReport: (request: SaveGeneratedReportRequest) => ipcRenderer.invoke('report:saveGenerated', request), + updateGeneratedReportTemplate: (request: UpdateGeneratedReportTemplateRequest) => + ipcRenderer.invoke('report:updateGeneratedTemplate', request), deleteGeneratedReport: (reportId: string) => ipcRenderer.invoke('report:deleteGenerated', reportId), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a91a09f..7caa11f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -31,6 +31,8 @@ import { sortMessagesChronologically } from './utils/message-pages' 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_MAX_WIDTH = 380 @@ -1462,7 +1464,9 @@ function App(): React.ReactElement { reportGeneration.phase !== 'success' || !reportSourceContact || !reportGeneration.generatedImage || - !reportGeneration.reportPaths + !reportGeneration.reportPaths || + !reportGeneration.reportSnapshot || + !reportGeneration.reportMetadata ) { return } @@ -1472,6 +1476,8 @@ function App(): React.ReactElement { lastCapturedReportKeyRef.current = recordKey setLatestGeneratedReportId(null) setIsSavingGeneratedReport(true) + const reportSnapshot = reportGeneration.reportSnapshot + const reportMetadata = reportGeneration.reportMetadata const saveReport = async (): Promise => { const result = await window.api.saveGeneratedReport({ @@ -1492,7 +1498,10 @@ function App(): React.ReactElement { duration: reportGeneration.generationMetadata.durationMs, modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model, tokenUsage: reportGeneration.generationMetadata.tokenUsage, - generationLogs: reportGeneration.generationMetadata.generationLogs + generationLogs: reportGeneration.generationMetadata.generationLogs, + reportSnapshot, + reportMetadata, + templateId: reportGeneration.templateId }) if (!result.success || !result.record) { @@ -1517,7 +1526,10 @@ function App(): React.ReactElement { reportGeneration.generationMetadata, reportGeneration.phase, reportGeneration.reportMessages.length, + reportGeneration.reportMetadata, reportGeneration.reportPaths, + reportGeneration.reportSnapshot, + reportGeneration.templateId, reportSourceContact, summaryDateRange ]) @@ -1564,6 +1576,30 @@ function App(): React.ReactElement { 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 ( reportId: string ): Promise<{ success: boolean; error?: string }> => { @@ -1634,6 +1670,7 @@ function App(): React.ReactElement { onRegenerate={handleRegenerateReport} onCopyImage={handleCopyReportImage} onReveal={handleRevealReport} + onSwitchTemplate={handleSwitchReportTemplate} />
@@ -1686,10 +1723,13 @@ function App(): React.ReactElement { error={reportGeneration.error} voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress} voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')} - onRetry={() => { - reportGeneration.resetGenerationStatus() - void reportGeneration.retry() - }} + preparationProgress={reportGeneration.preparationProgress} + imageInsightSummary={reportGeneration.imageInsightSummary} + canRetryModelStep={reportGeneration.canRetryModelStep} + currentModel={aiModelConfig} + onRetry={(model) => void reportGeneration.retry(model)} + onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()} + onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures} /> ) diff --git a/src/renderer/src/components/reports/AiReportWorkspace.tsx b/src/renderer/src/components/reports/AiReportWorkspace.tsx index 32d4129..aa1419c 100644 --- a/src/renderer/src/components/reports/AiReportWorkspace.tsx +++ b/src/renderer/src/components/reports/AiReportWorkspace.tsx @@ -15,7 +15,7 @@ import { ReportRangeSelector } from './ReportRangeSelector' import { ReportMemberNameSelector } from './ReportMemberNameSelector' import { ReportGroupMemberSelector } from './ReportGroupMemberSelector' import { ReportSectionSelector } from './ReportSectionSelector' -import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector' +import { ReportTemplateSelector, SelectableReportTemplateId } from './ReportTemplateSelector' interface AiReportWorkspaceProps { sourceContact: Contact | null @@ -40,8 +40,8 @@ interface AiReportWorkspaceProps { onRevealReport: () => Promise<{ success: boolean; error?: string }> onViewResult: () => void hasReportResult: boolean - templateId: ReportTemplateId - onTemplateIdChange: (value: ReportTemplateId) => void + templateId: SelectableReportTemplateId + onTemplateIdChange: (value: SelectableReportTemplateId) => void memberNamePreference: ReportMemberNamePreference onMemberNamePreferenceChange: (value: ReportMemberNamePreference) => void reportTimeoutSeconds: number diff --git a/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx b/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx index 1ceb9fd..ccbcd8c 100644 --- a/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx +++ b/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx @@ -1,31 +1,89 @@ import React, { useEffect, useState } from 'react' +import type { AIProviderSummary } from '../../../../shared/ai-provider' import { + AiModelConfig, REPORT_TASK_STEPS, ReportGenerationPhase, VoiceTranscriptionProgress } from '../../hooks/useGroupReportGeneration' +import type { + ReportImageInsightSummary, + ReportPreparationProgress +} from '../../utils/group-report-facts' interface ReportTaskStatusPanelProps { phase: ReportGenerationPhase error: string voiceTranscriptionProgress: VoiceTranscriptionProgress | null 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({ phase, error, voiceTranscriptionProgress, voiceTranscriptionEnabled, - onRetry + preparationProgress, + imageInsightSummary, + canRetryModelStep, + currentModel, + onRetry, + onContinueAfterImageFailures, + onCancelAfterImageFailures }: ReportTaskStatusPanelProps): React.ReactElement { const taskSteps = voiceTranscriptionEnabled ? REPORT_TASK_STEPS : REPORT_TASK_STEPS.filter((step) => step.id !== 'transcribingVoice') const activeIndex = taskSteps.findIndex((step) => step.id === phase) + const effectiveActiveIndex = + phase === 'awaitingImageDecision' + ? taskSteps.findIndex((step) => step.id === 'preparingInput') + : activeIndex const completedAll = phase === 'success' const [logPath, setLogPath] = useState('') + const [choices, setChoices] = useState([]) + const [selectedModelKey, setSelectedModelKey] = useState('') + const [modelLoadError, setModelLoadError] = useState('') useEffect(() => { void window.api @@ -34,6 +92,39 @@ export function ReportTaskStatusPanel({ .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 (