feat: 修改群聊日报生成与群成员信息展示

- 支持结构化 AI 群聊日报及移动端长图导出
- 支持日报时间范围和消息类型筛选
- 支持群成员及当前用户群昵称解析
- 移除消息加载更多并优化图片懒加载
- 增强头像处理与敏感密钥日志保护
This commit is contained in:
电摇小子
2026-06-29 16:43:01 +08:00
parent f6f8bc33f9
commit c00d581d9e
15 changed files with 1531 additions and 175 deletions
-1
View File
@@ -34,7 +34,6 @@
"better-sqlite3-multiple-ciphers": "^12.5.0", "better-sqlite3-multiple-ciphers": "^12.5.0",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"fzstd": "^0.1.1", "fzstd": "^0.1.1",
"html-to-image": "^1.11.13",
"koffi": "^2.9.0", "koffi": "^2.9.0",
"openai": "^6.10.0", "openai": "^6.10.0",
"silk-wasm": "^3.7.1" "silk-wasm": "^3.7.1"
-6
View File
@@ -26,7 +26,6 @@ specifiers:
eslint-plugin-react-refresh: ^0.4.24 eslint-plugin-react-refresh: ^0.4.24
fs-extra: ^11.3.2 fs-extra: ^11.3.2
fzstd: ^0.1.1 fzstd: ^0.1.1
html-to-image: ^1.11.13
koffi: ^2.9.0 koffi: ^2.9.0
openai: ^6.10.0 openai: ^6.10.0
prettier: ^3.7.4 prettier: ^3.7.4
@@ -42,7 +41,6 @@ dependencies:
better-sqlite3-multiple-ciphers: 12.5.0 better-sqlite3-multiple-ciphers: 12.5.0
fs-extra: 11.3.2 fs-extra: 11.3.2
fzstd: 0.1.1 fzstd: 0.1.1
html-to-image: 1.11.13
koffi: 2.16.2 koffi: 2.16.2
openai: 6.10.0 openai: 6.10.0
silk-wasm: 3.7.1 silk-wasm: 3.7.1
@@ -2946,10 +2944,6 @@ packages:
lru-cache: 6.0.0 lru-cache: 6.0.0
dev: true dev: true
/html-to-image/1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
dev: false
/http-cache-semantics/4.2.0: /http-cache-semantics/4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
+479
View File
@@ -0,0 +1,479 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{REPORT_TITLE}}</title>
<style>
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 0;
height: 0;
}
html {
width: 430px;
scrollbar-width: none;
}
body {
margin: 0;
width: 430px;
background: #f3f5f7;
color: #1f2933;
font-family:
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.report {
width: 430px;
margin: 0 auto;
padding: 22px 14px 34px;
}
.hero,
.card,
.section {
background: #fff;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
padding: 20px;
}
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.hero h1 {
font-size: 23px;
line-height: 1.2;
margin: 0 0 8px;
font-weight: 900;
}
.sub {
color: #667085;
font-size: 13px;
line-height: 1.5;
}
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
flex: 0 0 auto;
}
.avatar-grid img,
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 16px;
}
.stat {
background: #f7faf9;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
}
.stat span {
font-size: 11px;
color: #667085;
}
.section {
margin-top: 14px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 900;
margin-bottom: 12px;
}
.section-title::before {
content: '';
width: 5px;
height: 20px;
border-radius: 99px;
background: #07c160;
}
.card {
padding: 14px;
margin-top: 10px;
box-shadow: none;
border: 1px solid #edf0f2;
}
.topic-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.topic-title-row h3 {
font-size: 16px;
line-height: 1.35;
margin: 0;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.hot {
background: #fff4e5;
color: #d46b08;
}
.blue {
background: #eef5ff;
color: #1677ff;
}
.red {
background: #fff1f0;
color: #ff4d4f;
}
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
font-size: 13px;
line-height: 1.65;
margin: 10px 0 0;
}
.participants {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
}
.person-chip img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.person-chip b {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.keywords span {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: #f2f4f7;
color: #667085;
}
.resource {
padding: 11px 12px;
background: #f7f8fa;
border-radius: 12px;
margin-top: 8px;
font-size: 13px;
line-height: 1.55;
}
.resource b {
color: #1677ff;
}
.important-card {
display: flex;
gap: 10px;
background: #f7faf9;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.important-card > .avatar {
width: 36px;
height: 36px;
flex: 0 0 auto;
}
.important-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #667085;
}
.important-meta b {
color: #1f2933;
}
.important-text {
font-size: 13px;
line-height: 1.55;
margin-top: 5px;
}
.important-note {
margin-top: 8px;
padding: 7px 9px;
border-left: 3px solid #07c160;
background: #fff;
border-radius: 8px;
color: #07a352;
font-size: 12px;
line-height: 1.45;
}
.chat-block {
background: #f0f2f5;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.chat-msg {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.chat-name {
font-size: 11px;
color: #667085;
margin-bottom: 4px;
}
.chat-bubble {
background: #fff;
border-radius: 4px 12px 12px 12px;
padding: 9px 10px;
font-size: 13px;
line-height: 1.5;
}
.quote-note {
background: #fff8e1;
border-radius: 10px;
padding: 9px 10px;
margin-top: 10px;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.qa-card {
background: #f8fafc;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.qa-card b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
.qa-card div {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
.bar-row {
display: grid;
grid-template-columns: 82px 1fr;
gap: 8px;
align-items: center;
margin-top: 9px;
font-size: 12px;
}
.bar {
height: 10px;
background: #edf1f5;
border-radius: 999px;
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: #07c160;
border-radius: 999px;
}
.rank {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank img {
width: 30px;
height: 30px;
border-radius: 50%;
object-fit: cover;
}
.rank b {
font-size: 13px;
}
.rank span {
margin-left: auto;
color: #8a94a6;
font-size: 12px;
}
.cloud-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cloud-tags span {
padding: 6px 10px;
border-radius: 999px;
background: #f2f4f7;
color: #485465;
font-weight: 800;
}
.cloud-tags .xl {
font-size: 20px;
color: #07a352;
background: #e9f8ef;
}
.cloud-tags .lg {
font-size: 17px;
color: #1677ff;
background: #eef5ff;
}
.cloud-tags .md {
font-size: 15px;
color: #d46b08;
background: #fff4e5;
}
.footer {
padding: 16px 4px 0;
color: #98a2b3;
text-align: center;
font-size: 11px;
line-height: 1.8;
}
.muted {
color: #8a94a6;
}
.empty-section {
display: none;
}
@media (max-width: 430px) {
html,
body {
width: 100%;
}
.report {
width: 100%;
padding-left: 12px;
padding-right: 12px;
}
.stats {
gap: 6px;
}
.stat b {
font-size: 16px;
}
}
</style>
</head>
<body>
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TIME_SPAN}}</b><span>时间跨度</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
</div>
</header>
<section class="section topics">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
</section>
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
</section>
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息汇总</div>
{{IMPORTANT_MESSAGES}}
</section>
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">有趣对话或金句</div>
{{QUOTE_BLOCKS}}
</section>
<section class="section qa {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
</section>
<section class="section analytics">
<div class="section-title">群内数据可视化</div>
{{HEAT_BARS}}
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
话唠榜 TOP5(基于已读取记录估算)
</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
</div>
</section>
<section class="section cloud">
<div class="section-title">词云/关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
</section>
<footer class="footer">
数据来源:微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
</main>
</body>
</html>
+296
View File
@@ -0,0 +1,296 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import {
GroupReportExportRequest,
GroupReportExportResult,
ReportHeat
} from '../shared/group-report'
const TEMPLATE_NAME = 'mobile_daily_report.html'
const escapeHtml = (value: unknown): string =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
const sanitizeFileName = (value: string): string =>
value
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim() || '未命名群聊'
const hashName = (name: string): number => {
let hash = 0
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
return hash
}
const fallbackAvatar = (name: string): string => {
const hue = hashName(name) % 360
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}
const imageMimeType = (contentType: string | null, source: string): string => {
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
const extension = path.extname(source).toLowerCase()
if (extension === '.png') return 'image/png'
if (extension === '.webp') return 'image/webp'
if (extension === '.gif') return 'image/gif'
return 'image/jpeg'
}
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
if (!source) return fallbackAvatar(name)
if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
try {
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source, {
headers: {
'User-Agent': 'Mozilla/5.0 WechatExplorer',
Referer: 'https://weixin.qq.com/'
},
signal: AbortSignal.timeout(8000)
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const mime = imageMimeType(response.headers.get('content-type'), source)
return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
}
const localPath = source.startsWith('file://') ? new URL(source) : source
const buffer = await fs.readFile(localPath)
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
} catch (error) {
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
return fallbackAvatar(name)
}
}
const templatePath = (): string => {
const candidates = [
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
]
const found = candidates.find((candidate) => fs.existsSync(candidate))
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
return found
}
const heatClass = (heat: ReportHeat): string => {
if (heat === '高') return 'hot'
if (heat === '低') return 'blue'
return ''
}
const replacePlaceholder = (html: string, key: string, value: string): string =>
html.replaceAll(`{{${key}}}`, value)
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
const { report, metadata } = request
const avatarNames = new Set<string>(metadata.heroParticipants)
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
report.quotes.forEach((quote) =>
quote.messages.forEach((message) => avatarNames.add(message.sender))
)
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
const avatars = new Map<string, string>()
await Promise.all(
Array.from(avatarNames).map(async (name) => {
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
})
)
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
const heroNames = metadata.heroParticipants.slice(0, 4)
while (heroNames.length < 4) heroNames.push(metadata.groupName)
const heroAvatars = heroNames
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
.join('')
const topicCards = report.topics
.map(
(topic) => `<div class="card topic-card">
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
<p>${escapeHtml(topic.summary)}</p>
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
<div class="participants">${topic.participants
.slice(0, 5)
.map(
(name) =>
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
)
.join('')}</div>
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
</div>`
)
.join('')
const resourceItems = report.resources
.map(
(resource) =>
`<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`
)
.join('')
const importantMessages = report.importantMessages
.map(
(message) => `<div class="important-card">
<img class="avatar" src="${avatar(message.sender)}" alt="">
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
</div>`
)
.join('')
const quoteBlocks = report.quotes
.map(
(quote) =>
`<div class="chat-block">${quote.messages
.map(
(
message
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
</div></div>`
)
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
)
.join('')
const 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 maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
const heatBars = report.analytics.topicHeat
.map(
(item) =>
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
)
.join('')
const rankItems = report.analytics.topSpeakers
.slice(0, 5)
.map(
(speaker, index) =>
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
)
.join('')
const cloudTags = report.keywords
.slice(0, 15)
.map(
(word, index) =>
`<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`
)
.join('')
let html = await fs.readFile(templatePath(), 'utf8')
const values: Record<string, string> = {
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange),
RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()),
HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(metadata.messageCount),
ACTIVE_USERS: String(metadata.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan),
TOPIC_COUNT: String(report.topics.length),
TOPIC_CARDS: topicCards,
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
RESOURCE_ITEMS: resourceItems,
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
IMPORTANT_MESSAGES: importantMessages,
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
QUOTE_BLOCKS: quoteBlocks,
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
QA_CARDS: qaCards,
HEAT_BARS: heatBars,
RANK_ITEMS: rankItems,
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
CLOUD_TAGS: cloudTags,
GENERATED_AT: escapeHtml(metadata.generatedAt),
FOOTER_NOTE: escapeHtml(metadata.footerNote)
}
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
return html
}
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
const reportWindow = new BrowserWindow({
show: false,
width: 430,
height: 800,
frame: false,
backgroundColor: '#f3f5f7',
webPreferences: { sandbox: true }
})
try {
await reportWindow.loadFile(htmlPath)
await reportWindow.webContents.executeJavaScript(`Promise.all([
document.fonts.ready,
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
}))
])`)
reportWindow.webContents.debugger.attach('1.3')
const metrics = (await reportWindow.webContents.debugger.sendCommand(
'Page.getLayoutMetrics'
)) as { cssContentSize: { width: number; height: number } }
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
const height = Math.ceil(metrics.cssContentSize.height)
const screenshot = (await reportWindow.webContents.debugger.sendCommand(
'Page.captureScreenshot',
{
format: 'png',
captureBeyondViewport: true,
fromSurface: true,
clip: { x: 0, y: 0, width, height, scale: 1 }
}
)) as { data: string }
const png = Buffer.from(screenshot.data, 'base64')
if (png.length < 1000) throw new Error('生成的日报图片为空')
await fs.writeFile(pngPath, png)
return `data:image/png;base64,${screenshot.data}`
} finally {
if (reportWindow.webContents.debugger.isAttached()) {
reportWindow.webContents.debugger.detach()
}
reportWindow.destroy()
}
}
export const exportGroupReport = async (
request: GroupReportExportRequest
): Promise<GroupReportExportResult> => {
try {
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const html = await renderReportHtml(request)
await fs.writeFile(htmlPath, html, 'utf8')
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
return { success: true, htmlPath, pngPath, imageDataUrl }
} catch (error) {
console.error('[GroupReport] export failed:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
-3
View File
@@ -236,8 +236,6 @@ export class ImageDecryptService {
version, version,
'file:', 'file:',
datPath, datPath,
'xorKey:',
this.xorKey,
'aesKey present:', 'aesKey present:',
!!this.aesKey !!this.aesKey
) )
@@ -258,7 +256,6 @@ export class ImageDecryptService {
return null return null
} }
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16) const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
console.log('[ImageDecrypt] AES key bytes:', key.toString('hex'), 'length:', key.length)
decrypted = this.decryptDatV4(datPath, key) decrypted = this.decryptDatV4(datPath, key)
} }
+28 -12
View File
@@ -11,6 +11,8 @@ import {
parseStickerMessageFromRow parseStickerMessageFromRow
} from './message-parser' } from './message-parser'
import { ImageDecryptService } from './image-decrypt-service' import { ImageDecryptService } from './image-decrypt-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
let wechatDb: WechatDb | null = null let wechatDb: WechatDb | null = null
let voiceService: VoiceService | null = null let voiceService: VoiceService | null = null
@@ -53,8 +55,8 @@ function normalizeMsgType(value: string | number | undefined): number {
function createWindow(): void { function createWindow(): void {
// 创建浏览器窗口 // 创建浏览器窗口
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1200, width: 1400,
height: 670, height: 800,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}), ...(process.platform === 'linux' ? { icon } : {}),
@@ -102,9 +104,7 @@ app.whenReady().then(() => {
ipcMain.handle('db:init', (_, key: string) => { ipcMain.handle('db:init', (_, key: string) => {
try { try {
const trimmedKey = String(key || '').trim() const trimmedKey = String(key || '').trim()
console.log( console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length} keyPreview=${trimmedKey.slice(0, 6)}...${trimmedKey.slice(-6)}`
)
wechatDb = new WechatDb(key) wechatDb = new WechatDb(key)
const wcdb4Client = wechatDb.getWcdb4Client() const wcdb4Client = wechatDb.getWcdb4Client()
if (wcdb4Client) { if (wcdb4Client) {
@@ -175,6 +175,10 @@ app.whenReady().then(() => {
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime) const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
const groupMembers = wechatDb.getGroupMembersForChat(userMd5) const groupMembers = wechatDb.getGroupMembersForChat(userMd5)
const myAvatar = wechatDb.getMyAvatarUrl() const myAvatar = wechatDb.getMyAvatarUrl()
const myGroupNickname =
username?.endsWith('@chatroom') && wcdb4Client
? wcdb4Client.getMyGroupNickname(username)
: undefined
return rawMessages.map((msg: WechatMessage) => { return rawMessages.map((msg: WechatMessage) => {
const rawMsgType = parseInt(msg.messageType) const rawMsgType = parseInt(msg.messageType)
@@ -187,13 +191,12 @@ app.whenReady().then(() => {
let content = msg.msgContent let content = msg.msgContent
let img = '' let img = ''
let name = '' let name = ''
if (isMine && myAvatar) { if (isMine) {
img = myAvatar if (myAvatar) img = myAvatar
} else if (typeof msg.senderAvatar === 'string') { name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
img = msg.senderAvatar } else {
} if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
if (typeof msg.senderNickname === 'string') { if (typeof msg.senderNickname === 'string') name = msg.senderNickname
name = msg.senderNickname
} }
// 检查内容是否以 wxid 开头并包含冒号 // 检查内容是否以 wxid 开头并包含冒号
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content // 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
@@ -343,6 +346,19 @@ app.whenReady().then(() => {
} }
}) })
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
return exportGroupReport(request)
})
ipcMain.handle('report:reveal', async (_, filePath: string) => {
try {
shell.showItemInFolder(filePath)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle( ipcMain.handle(
'db:getVoiceData', 'db:getVoiceData',
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => { async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
+77 -10
View File
@@ -65,6 +65,7 @@ export class Wcdb4Client {
private initialized = false private initialized = false
private displayNameCache = new Map<string, string>() private displayNameCache = new Map<string, string>()
private avatarCache = new Map<string, string>() private avatarCache = new Map<string, string>()
private groupNicknameCache = new Map<string, Map<string, string>>()
private cachedSessions: Wcdb4Session[] | null = null private cachedSessions: Wcdb4Session[] | null = null
private wcdbInit: (() => number) | null = null private wcdbInit: (() => number) | null = null
@@ -96,6 +97,9 @@ export class Wcdb4Client {
private wcdbGetGroupMembers: private wcdbGetGroupMembers:
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number) | ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
| null = null | null = null
private wcdbGetGroupNicknames:
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
| null = null
private wcdbOpenMessageCursor: private wcdbOpenMessageCursor:
| (( | ((
handle: number, handle: number,
@@ -234,6 +238,7 @@ export class Wcdb4Client {
this.cachedSessions = null this.cachedSessions = null
this.displayNameCache.clear() this.displayNameCache.clear()
this.avatarCache.clear() this.avatarCache.clear()
this.groupNicknameCache.clear()
} }
getSessions(): Wcdb4Session[] { getSessions(): Wcdb4Session[] {
@@ -291,12 +296,7 @@ export class Wcdb4Client {
} }
getMyAvatarUrl(): string | undefined { getMyAvatarUrl(): string | undefined {
const rawAccountName = path.basename(this.accountRoot) const candidates = this.getMyUsernameCandidates()
const candidates = this.uniq([
this.wxid,
rawAccountName,
Wcdb4Client.cleanAccountDirName(rawAccountName)
])
this.hydrateAvatarUrls(candidates) this.hydrateAvatarUrls(candidates)
for (const candidate of candidates) { for (const candidate of candidates) {
@@ -307,6 +307,15 @@ export class Wcdb4Client {
return undefined return undefined
} }
getMyGroupNickname(chatroomId: string): string | undefined {
const groupNicknames = this.getGroupNicknames(chatroomId)
for (const candidate of this.getMyUsernameCandidates()) {
const nickname = groupNicknames.get(candidate)
if (nickname) return nickname
}
return undefined
}
private getMessagesByCursor( private getMessagesByCursor(
username: string, username: string,
startTime?: number, startTime?: number,
@@ -413,11 +422,12 @@ export class Wcdb4Client {
if (!this.wcdbGetGroupMembers || !chatroomId) return [] if (!this.wcdbGetGroupMembers || !chatroomId) return []
try { try {
const groupNicknames = this.getGroupNicknames(chatroomId)
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) => const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetGroupMembers!(handle, chatroomId, outJson) this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
) )
return (Array.isArray(rows) ? rows : []).map((row) => { const members = (Array.isArray(rows) ? rows : []).map((row) => {
const username = this.pickString(row, [ const username = this.pickString(row, [
'username', 'username',
'userName', 'userName',
@@ -425,10 +435,15 @@ export class Wcdb4Client {
'member_username', 'member_username',
'm_nsUsrName' 'm_nsUsrName'
]) ])
const nickname = this.pickString(row, [ const memberNickname = this.pickString(row, [
'nickname', 'nickname',
'nickName',
'displayName', 'displayName',
'display_name', 'display_name',
'groupNickname',
'group_nickname',
'roomNickname',
'room_nickname',
'remark', 'remark',
'm_nsNickName' 'm_nsNickName'
]) ])
@@ -440,21 +455,60 @@ export class Wcdb4Client {
]) ])
if (username) { if (username) {
if (nickname) this.displayNameCache.set(username, nickname)
if (avatar) this.avatarCache.set(username, avatar) if (avatar) this.avatarCache.set(username, avatar)
} }
return { return {
m_nsUsrName: username, m_nsUsrName: username,
nickname: nickname || username, nickname: groupNicknames.get(username) || memberNickname,
m_nsHeadImgUrl: avatar m_nsHeadImgUrl: avatar
} }
}) })
const missingDisplayNames = members
.filter((member) => !member.nickname)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
this.hydrateDisplayNames(missingDisplayNames)
return members.map((member) => ({
...member,
nickname:
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
} catch { } catch {
return [] return []
} }
} }
getGroupNicknames(chatroomId: string): Map<string, string> {
const cached = this.groupNicknameCache.get(chatroomId)
if (cached) return cached
const nicknames = new Map<string, string>()
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
try {
const rows = this.callJson<Record<string, string> | Record<string, unknown>[]>(
(handle, outJson) => this.wcdbGetGroupNicknames!(handle, chatroomId, outJson)
)
this.readStringMap(rows, [
'nickname',
'nickName',
'displayName',
'display_name',
'groupNickname',
'group_nickname',
'name'
]).forEach((nickname, username) => nicknames.set(username, nickname))
this.groupNicknameCache.set(chatroomId, nicknames)
} catch (error) {
console.warn(`[WCDB4] failed to get group nicknames for ${chatroomId}:`, error)
}
return nicknames
}
async getVoiceData( async getVoiceData(
sessionId: string, sessionId: string,
createTime: number, createTime: number,
@@ -650,6 +704,14 @@ export class Wcdb4Client {
this.wcdbGetGroupMembers = null this.wcdbGetGroupMembers = null
} }
try {
this.wcdbGetGroupNicknames = lib.func(
'int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)'
) as (handle: number, chatroomId: string, outJson: WcdbVoidOut) => number
} catch {
this.wcdbGetGroupNicknames = null
}
try { try {
this.wcdbOpenMessageCursor = lib.func( this.wcdbOpenMessageCursor = lib.func(
'int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)' 'int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)'
@@ -1168,6 +1230,11 @@ export class Wcdb4Client {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
} }
private getMyUsernameCandidates(): string[] {
const rawAccountName = path.basename(this.accountRoot)
return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)])
}
private normalizeTimestamp(input: number): number { private normalizeTimestamp(input: number): number {
if (!input || input <= 0) return 0 if (!input || input <= 0) return 0
const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input) const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input)
+1 -1
View File
@@ -53,7 +53,7 @@ export class WechatDb {
constructor(rawKey: string) { constructor(rawKey: string) {
this.rawKey = rawKey this.rawKey = rawKey
console.log(`Initializing WechatDb with key: ${rawKey}`) console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
if (this.tryOpenWechat4()) { if (this.tryOpenWechat4()) {
this.chatDb = this.getChatDbNumber() this.chatDb = this.getChatDbNumber()
+3
View File
@@ -1,5 +1,6 @@
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'
export type ParsedContent = export type ParsedContent =
| { type: 'text'; content: string } | { type: 'text'; content: string }
@@ -58,6 +59,8 @@ declare global {
cdnUrl?: string, cdnUrl?: string,
md5?: string md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }> ) => Promise<{ success: boolean; data?: string; error?: string }>
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
} }
} }
} }
+5 -1
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload'
import { GroupReportExportRequest } from '../shared/group-report'
// 渲染器的自定义 API // 渲染器的自定义 API
const api = { const api = {
@@ -19,7 +20,10 @@ const api = {
ipcRenderer.invoke('db:parseMessage', content, messageType), ipcRenderer.invoke('db:parseMessage', content, messageType),
getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) => getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) =>
ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId), ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5) getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath)
} }
if (process.contextIsolated) { if (process.contextIsolated) {
+73
View File
@@ -703,6 +703,79 @@ body {
overflow: auto; overflow: auto;
} }
.ai-settings-modal {
width: min(460px, 90vw);
}
.ai-settings-modal h3 {
margin: 0 0 18px;
}
.ai-filter-section {
margin-bottom: 16px;
}
.ai-filter-label {
margin-bottom: 7px;
color: #333;
font-size: 14px;
font-weight: 600;
}
.ai-date-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.ai-date-options label {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: 5px;
padding: 8px 6px;
border: 1px solid #d8dcdf;
border-radius: 6px;
background: #fff;
color: #4a5257;
cursor: pointer;
font-size: 13px;
}
.ai-date-options label.selected {
border-color: #07c160;
background: #eefaf3;
color: #078f49;
}
.ai-date-options input,
.ai-type-options input {
margin: 0;
accent-color: #07c160;
}
.ai-type-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 10px;
padding: 11px;
border: 1px solid #e1e4e6;
border-radius: 6px;
background: #f8f9fa;
}
.ai-type-options label {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
color: #3f474c;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.image-preview-modal { .image-preview-modal {
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
+128 -135
View File
@@ -1,9 +1,13 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { toPng } from 'html-to-image'
import { Message, Contact } from '../../../shared/types' import { Message, Contact } from '../../../shared/types'
import { VoicePlayer } from './VoicePlayer' import { VoicePlayer } from './VoicePlayer'
import { RichMessageBubble } from './RichMessageBubble' import { RichMessageBubble } from './RichMessageBubble'
import { ImageBubble } from './ImageBubble' import { ImageBubble } from './ImageBubble'
import {
buildGroupReportInput,
GROUP_REPORT_SYSTEM_PROMPT,
parseGroupDailyReport
} from '../utils/group-report'
interface ChatWindowProps { interface ChatWindowProps {
contact: Contact | null contact: Contact | null
@@ -13,27 +17,41 @@ interface ChatWindowProps {
onRefreshData?: () => void onRefreshData?: () => void
} }
const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。 type SummaryDateRange = 'today' | 'yesterday' | '7days'
请注意 不要回复总结除外的内容, 并且不要输出 群友的wxid 微信id 只需要显示群名称 type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system'
请帮我将给出的群聊内容总结成一个群聊报告,需要你生成7个最重要 最火爆的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
- 整体评价
- 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
- 参与者(不超过5个人,将重复的人名去重)
- 注意按时间排序,时间段(从日期几点到几点)
- 过程(50到200字左右)
- 评价(50字以下)
- 生成这7天内热度最高的话题,27日到2日一共7天
需要生成27, 28, 29, 30, 31, 1, 2日的话题总结
- 分割线: ------------
另外有以下要求: const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
1. 每个话题结束使用------------分割 { value: 'today', label: '今天' },
2. 使用中文冒号 { value: 'yesterday', label: '昨日' },
3. 无需大标题 { value: '7days', label: '最近 7 天' }
4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类 ]
5. 每个话题详细写出参与者
最后总结下今日最活跃的前五个发言者` const SUMMARY_TYPE_OPTIONS: {
value: SummaryMessageType
label: string
messageTypes: string[]
}[] = [
{ value: 'text', label: '文本', messageTypes: ['普通文本'] },
{ value: 'image', label: '图片', messageTypes: ['图片'] },
{ value: 'sticker', label: '表情包', messageTypes: ['表情包'] },
{ value: 'video', label: '视频', messageTypes: ['视频'] },
{ value: 'voice', label: '语音', messageTypes: ['语音'] },
{ value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] },
{ value: 'system', label: '系统消息', messageTypes: ['系统消息'] }
]
const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
const endTime = Math.floor(Date.now() / 1000)
if (range === 'yesterday') {
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
}
if (range === '7days') {
return { startTime: startOfToday - 6 * 86400, endTime }
}
return { startTime: startOfToday, endTime }
}
const ChatWindow: React.FC<ChatWindowProps> = ({ const ChatWindow: React.FC<ChatWindowProps> = ({
contact, contact,
@@ -42,10 +60,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onRefresh, onRefresh,
onRefreshData onRefreshData
}) => { }) => {
const isGroupChat = contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom') const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null) const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
const [previewImage, setPreviewImage] = useState<string | null>(null) const [previewImage, setPreviewImage] = useState<string | null>(null)
const [imageScale, setImageScale] = useState(0.75) const [imageScale, setImageScale] = useState(0.75)
const [imageRotation, setImageRotation] = useState(0) const [imageRotation, setImageRotation] = useState(0)
@@ -62,8 +82,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com' () => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
) )
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat') const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const handleSaveSettings = (): void => { const handleSaveSettings = (): void => {
if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return
}
localStorage.setItem('ai_api_key', apiKey) localStorage.setItem('ai_api_key', apiKey)
localStorage.setItem('ai_base_url', baseURL) localStorage.setItem('ai_base_url', baseURL)
localStorage.setItem('ai_model', model) localStorage.setItem('ai_model', model)
@@ -71,6 +97,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
AIChat() AIChat()
} }
const toggleSummaryMessageType = (type: SummaryMessageType): void => {
setSummaryMessageTypes((current) =>
current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
)
}
const scrollToBottom = (): void => { const scrollToBottom = (): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }) messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
} }
@@ -188,86 +220,52 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
document.body.removeChild(link) document.body.removeChild(link)
} }
const [summaryContent, setSummaryContent] = useState<string>('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const AIChat = async (): Promise<void> => { const AIChat = async (): Promise<void> => {
if (!messages || messages.length === 0) { if (!contact) return
alert('当前没有消息可供总结') if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return return
} }
const filteredMessages = messages
.filter((msg) => !'分享消息,图片,表情包,视频'.split(',').includes(msg.type))
.map((msg) => {
return {
from: msg.from,
type: msg.type,
datetime: msg.datetime,
content: msg.content,
name: msg.name
}
})
const recentMessages = filteredMessages
.map((msg) => {
return `${msg.datetime} ${msg.from}: ${msg.content}`
})
.join('\n')
const prompt = `请总结以下微信聊天记录的核心内容:\n\n${recentMessages}`
setIsLoading(true) setIsLoading(true)
try { try {
console.log('正在请求AI...') const { startTime, endTime } = getSummaryDateRange(summaryDateRange)
const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime)
const allowedTypes = new Set(
SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap(
(option) => option.messageTypes
)
)
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
const input = buildGroupReportInput(reportMessages, contact, isGroupChat)
console.log('🚀 ~ AIChat ~ input:', input)
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
const result = await window.api.aiChat( const result = await window.api.aiChat(
[ [
{ role: 'system', content: systemPrompt }, { role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: prompt } { role: 'user', content: input.prompt }
], ],
{ apiKey, model, baseURL } { apiKey, model, baseURL }
) )
if (result.success && result.data) { if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
console.log('AI Summary:', result.data) const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
setSummaryContent(result.data) const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
// 等待状态更新和渲染 throw new Error(exported.error || '日报文件生成失败')
setTimeout(() => {
textToImage()
setIsLoading(false) // 图片生成开始后停止加载
}, 500)
} else {
console.error('AI Error:', result.error)
alert(`AI 请求失败: ${result.error}`)
setIsLoading(false)
} }
setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
} catch (error) { } catch (error) {
console.error('AI Call Failed:', error) console.error('AI Call Failed:', error)
alert('AI 请求发生错误') alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`)
} finally {
setIsLoading(false) setIsLoading(false)
} }
} }
const textToImage = async (): Promise<void> => {
if (imageContainerRef.current) {
try {
const dataUrl = await toPng(imageContainerRef.current, {
cacheBust: true,
backgroundColor: '#ffffff',
style: {
transform: 'scale(1)'
}
})
if (dataUrl && dataUrl.length > 100) {
setGeneratedImage(dataUrl)
} else {
alert('生成图片为空')
}
} catch (err) {
console.error('Failed to generate image', err)
alert('生成图片失败: ' + (err instanceof Error ? err.message : String(err)))
}
}
}
const handleCopyImage = async (): Promise<void> => { const handleCopyImage = async (): Promise<void> => {
if (!generatedImage) return if (!generatedImage) return
const result = await window.api.copyImage(generatedImage) const result = await window.api.copyImage(generatedImage)
@@ -276,8 +274,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
} }
} }
const [displayLimit, setDisplayLimit] = useState(100)
const filteredMessages = React.useMemo(() => { const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => { return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '') const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
@@ -290,8 +286,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}) })
}, [messages, contentFilter]) }, [messages, contentFilter])
const visibleMessages = filteredMessages.slice(0, displayLimit)
if (!contact) { if (!contact) {
return ( return (
<div className="chat-window"> <div className="chat-window">
@@ -308,7 +302,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div> </div>
<div className="message-list wechat-message-list"> <div className="message-list wechat-message-list">
{visibleMessages.map((msg) => { {filteredMessages.map((msg) => {
const isMine = msg.from === 'assistant' const isMine = msg.from === 'assistant'
const displayName = isMine const displayName = isMine
? '我' ? '我'
@@ -368,22 +362,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div> </div>
) )
})} })}
{filteredMessages.length > displayLimit && (
<div style={{ textAlign: 'center', padding: '10px' }}>
<button
onClick={() => setDisplayLimit((prev) => prev + 100)}
style={{
padding: '8px 16px',
backgroundColor: '#f0f0f0',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer'
}}
>
({filteredMessages.length - displayLimit} )
</button>
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
@@ -425,42 +403,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</button> </button>
</div> </div>
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: 0,
height: 0,
overflow: 'hidden',
zIndex: -1
}}
>
<div
style={{
width: '820px',
padding: '20px',
backgroundColor: '#fff',
fontSize: '22px',
color: '#000',
whiteSpace: 'pre-wrap',
fontFamily: 'sans-serif',
lineHeight: '1.5'
}}
ref={imageContainerRef}
>
{summaryContent}
</div>
</div>
{/* 加载模态框 */} {/* 加载模态框 */}
{isLoading && ( {isLoading && (
<div className="modal-overlay"> <div className="modal-overlay">
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}> <div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div> <div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
<div style={{ fontSize: '16px', color: '#333' }}> AI ...</div> <div style={{ fontSize: '16px', color: '#333' }}>...</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}> <div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
HTML
</div> </div>
</div> </div>
</div> </div>
@@ -496,6 +446,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
> >
📋 📋
</button> </button>
{reportPaths && (
<button
onClick={() => window.api.revealGroupReport(reportPaths.pngPath)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
>
</button>
)}
<button <button
onClick={() => setGeneratedImage(null)} onClick={() => setGeneratedImage(null)}
style={{ padding: '5px 10px', cursor: 'pointer' }} style={{ padding: '5px 10px', cursor: 'pointer' }}
@@ -559,8 +517,43 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{/* AI Settings Modal */} {/* AI Settings Modal */}
{showSettingsModal && ( {showSettingsModal && (
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}> <div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}> <div className="modal-content ai-settings-modal" onClick={(e) => e.stopPropagation()}>
<h3>AI </h3> <h3>AI </h3>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-date-options">
{SUMMARY_DATE_OPTIONS.map((option) => (
<label
key={option.value}
className={summaryDateRange === option.value ? 'selected' : ''}
>
<input
type="radio"
name="summary-date-range"
value={option.value}
checked={summaryDateRange === option.value}
onChange={() => setSummaryDateRange(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-type-options">
{SUMMARY_TYPE_OPTIONS.map((option) => (
<label key={option.value}>
<input
type="checkbox"
checked={summaryMessageTypes.includes(option.value)}
onChange={() => toggleSummaryMessageType(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}> <div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>:</label> <label style={{ display: 'block', marginBottom: '5px' }}>:</label>
<select <select
+18 -5
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react' import type { JSX, MouseEvent } from 'react'
interface ImageBubbleProps { interface ImageBubbleProps {
@@ -19,6 +19,7 @@ export function ImageBubble({
const [imageUrl, setImageUrl] = useState<string | null>(null) const [imageUrl, setImageUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const loadImage = useCallback(async () => { const loadImage = useCallback(async () => {
if (imageUrl || loading) return if (imageUrl || loading) return
@@ -50,10 +51,22 @@ export function ImageBubble({
useEffect(() => { useEffect(() => {
if (imageUrl || loading || error) return if (imageUrl || loading || error) return
const timer = window.setTimeout(() => { const element = containerRef.current
void loadImage() if (!element || typeof IntersectionObserver === 'undefined') {
}, 0) const timer = window.setTimeout(() => void loadImage(), 0)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return
observer.disconnect()
void loadImage()
},
{ rootMargin: '400px 0px' }
)
observer.observe(element)
return () => observer.disconnect()
}, [error, imageUrl, loadImage, loading]) }, [error, imageUrl, loadImage, loading])
const handleCopy = async (event: MouseEvent): Promise<void> => { const handleCopy = async (event: MouseEvent): Promise<void> => {
@@ -88,7 +101,7 @@ export function ImageBubble({
if (!imageUrl) { if (!imageUrl) {
return ( return (
<div className="image-bubble image-placeholder"> <div ref={containerRef} className="image-bubble image-placeholder">
<div className="image-placeholder-icon">🖼</div> <div className="image-placeholder-icon">🖼</div>
<div className="image-placeholder-text"></div> <div className="image-placeholder-text"></div>
</div> </div>
+330
View File
@@ -0,0 +1,330 @@
import { Contact, Message } from '../../../shared/types'
import {
GroupDailyReport,
GroupReportMetadata,
ReportHeat,
ReportImportantMessage,
ReportQuestionAnswer,
ReportQuote,
ReportResource,
ReportSpeakerRank,
ReportTopic
} from '../../../shared/group-report'
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
原则:
1. 不得编造聊天中没有的事实、结论、参与者或链接内容。
2. 仅使用输入中的昵称,不输出 wxid、微信 ID、会话 ID 等内部标识。
3. 图片、表情、语音、视频或链接内容不可见时,仅标注消息类型,不要猜测。
4. 摘要说明发生了什么、大家如何回应、最后形成什么结论或氛围。
5. 语气准确、轻巧、有信息密度,避免侮辱性和歧视性评价。
6. 没有实际内容的可选栏目输出空数组,不要凑数。
7. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
JSON 结构必须为:
{
"overview": "1至2句整体讨论风格与氛围",
"topics": [{
"title": "话题标题",
"timeRange": "HH:mm-HH:mm",
"heat": "高|中|低",
"participants": ["昵称"],
"summary": "话题摘要",
"conclusion": "结论或氛围",
"keywords": ["关键词"]
}],
"resources": [{"title":"资源名","description":"用途或内容","sender":"昵称"}],
"importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要"}],
"quotes": [{"messages":[{"sender":"昵称","content":"简短原话"}],"note":"点评"}],
"qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称"}],
"keywords": ["关键词"]
}
topics 提取 3 至 7 个,参与者最多 5 人,quotes 最多 3 组,keywords 输出 8 至 15 个。`
const isInternalIdentifier = (value: string): boolean =>
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
if (message.from === 'assistant') {
const ownGroupNickname = message.name?.trim()
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
return ownGroupNickname
}
return '我'
}
const candidate = isGroup ? message.name : contact?.m_nsNickName
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
return candidate
}
const summaryContent = (message: Message): string => {
const data = message.contentData
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
switch (data.type) {
case 'image':
return '[图片]'
case 'sticker':
return '[表情]'
case 'voice':
return `[语音${data.duration ? ` ${data.duration}` : ''}]`
case 'share':
return `[分享] ${data.title}${data.des ? `${data.des}` : ''}`
case 'quote': {
const reply = data.title || data.content || message.content || '[回复]'
const quotedSender =
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
return `${reply}(引用 ${quotedSender}${data.quotedContent || `[引用${data.quotedType || '消息'}]`}`
}
case 'location':
return `[位置] ${data.poiname || data.label || '位置消息'}`
case 'card':
return `[名片] ${data.nickname || '微信名片'}`
case 'voip':
return `[通话] ${data.status}${data.duration ? `${data.duration}` : ''}`
case 'system':
case 'text':
return data.content
case 'unknown':
return `[${message.type || '未知消息'}]`
}
return `[${message.type || '消息'}]`
}
const localDate = (timestamp: number): string => {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const localTime = (timestamp: number): string =>
new Date(timestamp).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit'
})
export interface GroupReportInput {
prompt: string
metadata: GroupReportMetadata
topSpeakers: ReportSpeakerRank[]
activeTimeline: string
}
export const buildGroupReportInput = (
messages: Message[],
contact: Contact | null,
isGroup: boolean
): GroupReportInput => {
const rows = messages.map((message) => ({
datetime: message.datetime,
timestamp: new Date(message.datetime).getTime(),
sender: summarySender(message, contact, isGroup),
content: summaryContent(message),
avatar: message.img
}))
let firstTimestamp = Number.POSITIVE_INFINITY
let lastTimestamp = Number.NEGATIVE_INFINITY
for (const row of rows) {
if (!Number.isFinite(row.timestamp)) continue
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
}
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
const speakerCounts = new Map<string, number>()
const hourCounts = new Map<number, number>()
const avatars: Record<string, string | undefined> = {}
for (const row of rows) {
speakerCounts.set(row.sender, (speakerCounts.get(row.sender) || 0) + 1)
if (Number.isFinite(row.timestamp)) {
const hour = new Date(row.timestamp).getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
if (row.avatar && !avatars[row.sender]) avatars[row.sender] = row.avatar
}
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 5)
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 4)
.sort((left, right) => left.hour - right.hour)
.map(
({ hour, count }) =>
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59${count}条)`
)
.join('、')
const startDate = localDate(firstTimestamp)
const endDate = localDate(lastTimestamp)
const sameDay = startDate === endDate
const dateRange = sameDay
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
: `${startDate} ${localTime(firstTimestamp)}${endDate} ${localTime(lastTimestamp)}`
const timeSpan = sameDay
? `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 3600000))}小时`
: `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 86400000))}`
const contactName = contact?.m_nsNickName || ''
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
const metadata: GroupReportMetadata = {
groupName,
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
dateRange,
messageCount: rows.length,
activeUsers: speakerCounts.size,
timeSpan,
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
recordNote: `基于当前已加载的 ${rows.length} 条记录`,
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容仅按类型统计。',
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
avatars
}
const transcript = rows.map((row) => `${row.datetime} ${row.sender}${row.content}`).join('\n')
const prompt = `请为以下微信${isGroup ? '群聊' : '对话'}记录生成日报 JSON。
会话名:${groupName}
时间范围:${dateRange}
消息数:${rows.length}
活跃人数:${speakerCounts.size}
完整性:仅基于当前应用已加载的记录。
聊天记录:
${transcript}`
return { prompt, metadata, topSpeakers, activeTimeline }
}
const asObject = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
const asString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const asName = (value: unknown): string => {
const name = asString(value)
return name && !isInternalIdentifier(name) ? name : '未命名群成员'
}
const asNumber = (value: unknown): number => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
const asStrings = (value: unknown, limit = 20): string[] =>
asArray(value).map(asString).filter(Boolean).slice(0, limit)
const normalizeHeat = (value: unknown): ReportHeat => {
const heat = asString(value)
if (heat.includes('高')) return '高'
if (heat.includes('低')) return '低'
return '中'
}
const extractJson = (raw: string): unknown => {
const cleaned = raw
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON')
return JSON.parse(cleaned.slice(start, end + 1))
}
export const parseGroupDailyReport = (
raw: string,
topSpeakers: ReportSpeakerRank[],
activeTimeline: string
): GroupDailyReport => {
const root = asObject(extractJson(raw))
const topics: ReportTopic[] = asArray(root.topics)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
timeRange: asString(item.timeRange),
heat: normalizeHeat(item.heat),
participants: asArray(item.participants).map(asName).filter(Boolean).slice(0, 5),
summary: asString(item.summary),
conclusion: asString(item.conclusion),
keywords: asStrings(item.keywords, 8)
}
})
.filter((topic) => topic.title && topic.summary)
.slice(0, 7)
if (!topics.length) throw new Error('AI 日报中没有有效话题')
const resources: ReportResource[] = asArray(root.resources)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
description: asString(item.description),
sender: item.sender ? asName(item.sender) : undefined
}
})
.filter((item) => item.title && item.description)
const importantMessages: ReportImportantMessage[] = asArray(root.importantMessages)
.map((value) => {
const item = asObject(value)
return {
sender: asName(item.sender),
time: asString(item.time),
content: asString(item.content),
note: asString(item.note)
}
})
.filter((item) => item.sender && item.content)
const quotes: ReportQuote[] = asArray(root.quotes)
.map((value) => {
const item = asObject(value)
return {
messages: asArray(item.messages)
.map((messageValue) => {
const message = asObject(messageValue)
return { sender: asName(message.sender), content: asString(message.content) }
})
.filter((message) => message.sender && message.content),
note: asString(item.note)
}
})
.filter((quote) => quote.messages.length)
.slice(0, 3)
const qa: ReportQuestionAnswer[] = asArray(root.qa)
.map((value) => {
const item = asObject(value)
return {
question: asString(item.question),
answer: asString(item.answer),
answerer: item.answerer ? asName(item.answerer) : undefined
}
})
.filter((item) => item.question && item.answer)
return {
overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
topics,
resources,
importantMessages,
quotes,
qa,
analytics: {
topicHeat: topics.map((topic) => ({
topic: topic.title,
score: topic.heat === '高' ? 100 : topic.heat === '中' ? 65 : 35
})),
activeTimeline: activeTimeline || '暂无可用时间统计',
topSpeakers: topSpeakers.map((speaker) => ({
name: speaker.name,
count: Math.max(0, asNumber(speaker.count))
}))
},
keywords: asStrings(root.keywords, 15)
}
}
+92
View File
@@ -0,0 +1,92 @@
export type ReportHeat = '高' | '中' | '低'
export interface ReportTopic {
title: string
timeRange: string
heat: ReportHeat
participants: string[]
summary: string
conclusion?: string
keywords: string[]
}
export interface ReportResource {
title: string
description: string
sender?: string
}
export interface ReportImportantMessage {
sender: string
time: string
content: string
note: string
}
export interface ReportQuoteMessage {
sender: string
content: string
}
export interface ReportQuote {
messages: ReportQuoteMessage[]
note: string
}
export interface ReportQuestionAnswer {
question: string
answer: string
answerer?: string
}
export interface ReportTopicHeat {
topic: string
score: number
}
export interface ReportSpeakerRank {
name: string
count: number
}
export interface GroupDailyReport {
overview: string
topics: ReportTopic[]
resources: ReportResource[]
importantMessages: ReportImportantMessage[]
quotes: ReportQuote[]
qa: ReportQuestionAnswer[]
analytics: {
topicHeat: ReportTopicHeat[]
activeTimeline: string
topSpeakers: ReportSpeakerRank[]
}
keywords: string[]
}
export interface GroupReportMetadata {
groupName: string
reportDate: string
dateRange: string
messageCount: number
activeUsers: number
timeSpan: string
generatedAt: string
recordNote: string
footerNote: string
heroParticipants: string[]
avatars: Record<string, string | undefined>
}
export interface GroupReportExportRequest {
report: GroupDailyReport
metadata: GroupReportMetadata
}
export interface GroupReportExportResult {
success: boolean
htmlPath?: string
pngPath?: string
imageDataUrl?: string
error?: string
}