mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 增加日报模板
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.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) => `
|
||||||
|
<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'))
|
||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Vendored
+18
-2
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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('手机端 · 375–414 px', mobileTemplates)}
|
||||||
|
{renderGroup('电脑端 · 1280–1920 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&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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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('手机端 · 375–414 px')).toBeVisible()
|
||||||
|
expect(screen.getByText('电脑端 · 1280–1920 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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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:00–18: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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user