From 519c10223ded0dd1d8e41a2b02cff750c7bb5af0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <969409112@qq.com>
Date: Sun, 12 Jul 2026 19:58:01 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=20AI=20=E6=97=A5?=
=?UTF-8?q?=E6=8A=A5=E8=AF=A6=E6=83=85=E7=8A=B6=E6=80=81=E4=B8=8E=E8=A7=A3?=
=?UTF-8?q?=E6=9E=90=E5=AE=B9=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/group-report-service.ts | 20 +-
src/main/index.ts | 112 ++++-----
src/main/report-history-service.ts | 53 ++++-
src/preload/index.d.ts | 40 +++-
src/renderer/src/App.tsx | 164 +++++++------
src/renderer/src/assets/main.css | 55 ++++-
.../components/reports/ReportExportStatus.tsx | 6 +-
.../components/reports/ReportInfoPanel.tsx | 216 ++++++++++++++++++
.../reports/ReportSettingsPanel.tsx | 4 +-
.../reports/ReportTaskStatusPanel.tsx | 21 +-
.../src/components/reports/ReportViewer.tsx | 14 +-
.../src/hooks/useGroupReportGeneration.ts | 151 +++++++++---
src/renderer/src/utils/group-report.ts | 70 +++++-
src/shared/group-report.ts | 12 +
src/shared/report-history.ts | 36 +++
15 files changed, 777 insertions(+), 197 deletions(-)
create mode 100644 src/renderer/src/components/reports/ReportInfoPanel.tsx
diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts
index a7e9548..db52b4b 100644
--- a/src/main/group-report-service.ts
+++ b/src/main/group-report-service.ts
@@ -106,9 +106,7 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise | null = null
+let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
+ null
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
const TRAY_MODE =
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
@@ -69,7 +63,10 @@ function normalizeImageXorKey(value: unknown): string {
? Number.parseInt(raw.slice(2), 16)
: Number.parseInt(raw, 10)
if (!Number.isFinite(parsed)) return raw
- return `0x${Math.max(0, parsed & 0xff).toString(16).toUpperCase().padStart(2, '0')}`
+ return `0x${Math.max(0, parsed & 0xff)
+ .toString(16)
+ .toUpperCase()
+ .padStart(2, '0')}`
}
function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
@@ -80,7 +77,6 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
}
}
-
function createWindow(): void {
// 鍒涘缓娴忚鍣ㄧ獥鍙?
const mainWindow = new BrowserWindow({
@@ -145,40 +141,40 @@ app.whenReady().then(async () => {
if (dbInitInFlight) return dbInitInFlight
dbInitInFlight = (async () => {
- try {
- const trimmedKey = String(key || '').trim()
- console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
- const settings = loadSettings()
- if (
- chat.isReady() &&
- chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
- (!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot)
- ) {
- console.log('[WCDB4] db:init reuse current connection')
- return { success: true, monitoring: true }
- }
- const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
- const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
- if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
- saveSettings({ ...settings, dbRoot: resolvedRoot })
- }
- chat.setChatDb(nextWechatDb)
- const wcdb4Client = nextWechatDb.getWcdb4Client()
- voiceService = new VoiceService(wcdb4Client)
- stickerService = new StickerService(wcdb4Client)
- const monitoring = wcdb4Client.startMonitor((type, json) => {
- for (const window of BrowserWindow.getAllWindows()) {
- if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
+ try {
+ const trimmedKey = String(key || '').trim()
+ console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
+ const settings = loadSettings()
+ if (
+ chat.isReady() &&
+ chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
+ (!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot)
+ ) {
+ console.log('[WCDB4] db:init reuse current connection')
+ return { success: true, monitoring: true }
}
- })
- imageDecryptService = null
- return { success: true, monitoring }
- } catch (error) {
- console.error('Failed to init DB:', error)
- return { success: false, error: error instanceof Error ? error.message : String(error) }
- } finally {
- dbInitInFlight = null
- }
+ const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
+ const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
+ if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
+ saveSettings({ ...settings, dbRoot: resolvedRoot })
+ }
+ chat.setChatDb(nextWechatDb)
+ const wcdb4Client = nextWechatDb.getWcdb4Client()
+ voiceService = new VoiceService(wcdb4Client)
+ stickerService = new StickerService(wcdb4Client)
+ const monitoring = wcdb4Client.startMonitor((type, json) => {
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
+ }
+ })
+ imageDecryptService = null
+ return { success: true, monitoring }
+ } catch (error) {
+ console.error('Failed to init DB:', error)
+ return { success: false, error: error instanceof Error ? error.message : String(error) }
+ } finally {
+ dbInitInFlight = null
+ }
})()
return dbInitInFlight
@@ -191,7 +187,9 @@ app.whenReady().then(async () => {
return databaseKeyStore.save(clipboardKey)
})
- ipcMain.handle('key:saveDbKey', async (_, key: string) => databaseKeyStore.save(String(key || '')))
+ ipcMain.handle('key:saveDbKey', async (_, key: string) =>
+ databaseKeyStore.save(String(key || ''))
+ )
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
@@ -258,7 +256,9 @@ app.whenReady().then(async () => {
ipcMain.handle('db:getContacts', (_, filter?: string) => {
const accountRoot = chat.getCurrentAccountRoot()
- const contacts = accountRoot ? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter)) : chat.listContacts(filter)
+ const contacts = accountRoot
+ ? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter))
+ : chat.listContacts(filter)
if (!filter && chat.isReady() && accountRoot) {
saveBootstrapContacts(accountRoot, contacts)
}
@@ -316,7 +316,18 @@ app.whenReady().then(async () => {
messages: messages as any,
model: model
})
- return { success: true, data: completion.choices[0].message.content }
+ return {
+ success: true,
+ data: completion.choices[0].message.content,
+ usage: completion.usage
+ ? {
+ input: completion.usage.prompt_tokens,
+ output: completion.usage.completion_tokens,
+ total: completion.usage.total_tokens,
+ estimated: false
+ }
+ : undefined
+ }
} catch (error: unknown) {
console.error('AI API Error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -441,10 +452,7 @@ app.whenReady().then(async () => {
ipcMain.handle('settings:set', (_, patch: Partial) => {
const before = loadSettings()
const merged = saveSettings({ ...before, ...patch })
- if (
- before.imageXorKey !== merged.imageXorKey ||
- before.imageAesKey !== merged.imageAesKey
- ) {
+ if (before.imageXorKey !== merged.imageXorKey || before.imageAesKey !== merged.imageAesKey) {
imageDecryptService = null
}
return { settings: merged, settingsPath: getSettingsPath() }
diff --git a/src/main/report-history-service.ts b/src/main/report-history-service.ts
index 4bea158..7753ffc 100644
--- a/src/main/report-history-service.ts
+++ b/src/main/report-history-service.ts
@@ -1,4 +1,4 @@
-import { app } from 'electron'
+import { app, nativeImage } from 'electron'
import { promises as fs } from 'fs'
import path from 'path'
import type {
@@ -50,6 +50,26 @@ const readPngAsDataUrl = async (filePath?: string): Promise
return `data:image/png;base64,${content.toString('base64')}`
}
+const readPngSize = async (
+ filePath?: string
+): Promise<{ width: number; height: number } | undefined> => {
+ if (!filePath || !(await exists(filePath))) return undefined
+ const image = nativeImage.createFromPath(filePath)
+ if (image.isEmpty()) return undefined
+ const size = image.getSize()
+ return size.width && size.height ? size : undefined
+}
+
+const readFileSize = async (filePath?: string): Promise => {
+ if (!filePath) return undefined
+ try {
+ const stat = await fs.stat(filePath)
+ return stat.isFile() ? stat.size : undefined
+ } catch {
+ return undefined
+ }
+}
+
const normalizeRecord = async (
record: GeneratedReportRecord,
jsonPath: string
@@ -61,7 +81,12 @@ const normalizeRecord = async (
jsonPath,
htmlStatus,
pngStatus,
- generatedImage: await readPngAsDataUrl(record.pngPath)
+ generatedImage: await readPngAsDataUrl(record.pngPath),
+ imageSize: await readPngSize(record.pngPath),
+ fileSize: {
+ html: await readFileSize(record.htmlPath),
+ png: await readFileSize(record.pngPath)
+ }
}
}
@@ -109,9 +134,7 @@ export async function saveGeneratedReport(
): Promise {
try {
const generatedAtDate = new Date(request.generatedAt)
- const timestamp = Number.isFinite(generatedAtDate.getTime())
- ? generatedAtDate
- : new Date()
+ const timestamp = Number.isFinite(generatedAtDate.getTime()) ? generatedAtDate : new Date()
const year = String(timestamp.getFullYear())
const month = pad2(timestamp.getMonth() + 1)
const directory = path.join(getReportsRoot(), year, month)
@@ -152,7 +175,16 @@ export async function saveGeneratedReport(
pngPath: savedPngPath,
jsonPath,
htmlStatus: savedHtmlPath ? 'ready' : 'missing',
- pngStatus: savedPngPath ? 'ready' : 'missing'
+ pngStatus: savedPngPath ? 'ready' : 'missing',
+ imageSize: await readPngSize(savedPngPath),
+ duration: request.duration,
+ modelName: request.modelName,
+ tokenUsage: request.tokenUsage,
+ fileSize: {
+ html: await readFileSize(savedHtmlPath),
+ png: await readFileSize(savedPngPath)
+ },
+ generationLogs: request.generationLogs
}
await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8')
@@ -168,7 +200,9 @@ export async function saveGeneratedReport(
}
}
-export async function deleteGeneratedReport(reportId: string): Promise {
+export async function deleteGeneratedReport(
+ reportId: string
+): Promise {
try {
const jsonFiles = await walkJsonFiles(getReportsRoot())
for (const jsonPath of jsonFiles) {
@@ -191,7 +225,10 @@ export async function deleteGeneratedReport(reportId: string): Promise Promise<{ success: boolean; data?: string; error?: string }>
+ ) => Promise<{
+ success: boolean
+ data?: string
+ usage?: {
+ input?: number
+ output?: number
+ total?: number
+ estimated?: boolean
+ }
+ error?: string
+ }>
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
getVoiceData: (
sessionId: string,
@@ -84,7 +94,13 @@ declare global {
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { force?: boolean }
- ) => Promise<{ success: boolean; data?: string; error?: string; isThumb?: boolean; filePath?: string }>
+ ) => Promise<{
+ success: boolean
+ data?: string
+ error?: string
+ isThumb?: boolean
+ filePath?: string
+ }>
getSticker: (
cdnUrl?: string,
md5?: string
@@ -141,15 +157,17 @@ declare global {
}
settingsPath: string
}>
- setSettings: (patch: Partial<{
- dbRoot: string
- apiEnabled: boolean
- apiHost: string
- apiPort: number
- imageKeyRoot: string
- imageXorKey: string
- imageAesKey: string
- }>) => Promise<{
+ setSettings: (
+ patch: Partial<{
+ dbRoot: string
+ apiEnabled: boolean
+ apiHost: string
+ apiPort: number
+ imageKeyRoot: string
+ imageXorKey: string
+ imageAesKey: string
+ }>
+ ) => Promise<{
settings: {
dbRoot: string
apiEnabled: boolean
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 1f69d3e..fad81c5 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -6,16 +6,13 @@ import { AppShell } from './components/layout/AppShell'
import { AppPage } from './components/layout/navigation'
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
-import { ReportSettingsPanel } from './components/reports/ReportSettingsPanel'
+import { ReportInfoPanel } from './components/reports/ReportInfoPanel'
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
import { ReportViewer } from './components/reports/ReportViewer'
import { contactDisplayName } from './components/reports/types'
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
-import {
- AiModelConfig,
- useGroupReportGeneration
-} from './hooks/useGroupReportGeneration'
+import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types'
@@ -33,14 +30,7 @@ function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
strokeLinecap="round"
strokeLinejoin="round"
/>
-
+
{hidden && (
{
@@ -175,10 +167,8 @@ function App(): React.ReactElement {
const [reportWorkspaceView, setReportWorkspaceView] = useState('result')
const [generatedReports, setGeneratedReports] = useState([])
const [selectedReportId, setSelectedReportId] = useState(null)
- const [selectedReportImageSize, setSelectedReportImageSize] = useState<{
- width: number
- height: number
- } | null>(null)
+ const [latestGeneratedReportId, setLatestGeneratedReportId] = useState(null)
+ const [isSavingGeneratedReport, setIsSavingGeneratedReport] = useState(false)
const [reportNotice, setReportNotice] = useState('')
const [summaryDateRange, setSummaryDateRange] = useState('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState(['text'])
@@ -225,8 +215,7 @@ function App(): React.ReactElement {
}
}, [])
- const waitForPaint = (): Promise =>
- new Promise((resolve) => window.setTimeout(resolve, 80))
+ const waitForPaint = (): Promise => new Promise((resolve) => window.setTimeout(resolve, 80))
const refreshSelfInfo = async (): Promise => {
try {
@@ -317,10 +306,18 @@ function App(): React.ReactElement {
)
if (Object.keys(avatars).length === 0) continue
setContacts((current) =>
- current.map((contact) => avatars[contact.m_nsUsrName] ? { ...contact, avatar: avatars[contact.m_nsUsrName] } : contact)
+ current.map((contact) =>
+ avatars[contact.m_nsUsrName]
+ ? { ...contact, avatar: avatars[contact.m_nsUsrName] }
+ : contact
+ )
)
setFilteredContacts((current) =>
- current.map((contact) => avatars[contact.m_nsUsrName] ? { ...contact, avatar: avatars[contact.m_nsUsrName] } : contact)
+ current.map((contact) =>
+ avatars[contact.m_nsUsrName]
+ ? { ...contact, avatar: avatars[contact.m_nsUsrName] }
+ : contact
+ )
)
} catch (error) {
console.warn('[Contacts] avatar hydrate failed:', error)
@@ -375,9 +372,7 @@ function App(): React.ReactElement {
void refreshSelfInfo()
} else {
const error = typeof result === 'boolean' ? '' : result.error
- setDbKeyStatus(
- `自动连接失败,请重新输入${error ? `: ${error}` : ''}`
- )
+ setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
setDbKeyStatusKind('error')
setBootState('login')
}
@@ -399,6 +394,8 @@ function App(): React.ReactElement {
active = false
unsubscribe()
}
+ // 自动连接只在应用启动时执行一次,避免联系人加载刷新触发重复连接。
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
React.useEffect(() => {
@@ -658,7 +655,10 @@ function App(): React.ReactElement {
if (selectedContactMd5Ref.current !== contact.md5) return
if (!snapshot) return
setMessages((current) =>
- applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
+ applyGroupMemberMeta(
+ contact,
+ mergeSyntheticMessages(contact, current, snapshot.roomId)
+ )
)
})
}, 120)
@@ -672,23 +672,33 @@ function App(): React.ReactElement {
setDateRange(range)
if (selectedContact) {
const { startTime, endTime } = getDateRangeParams(range)
- window.api.getCachedMessages(selectedContact.md5, startTime, endTime).then((cachedMessages) => {
- if (!cachedMessages.length) return
- setMessages(
- applyGroupMemberMeta(
- selectedContact,
- mergeSyntheticMessages(selectedContact, cachedMessages.slice(-VIEW_MESSAGE_LIMIT))
+ window.api
+ .getCachedMessages(selectedContact.md5, startTime, endTime)
+ .then((cachedMessages) => {
+ if (!cachedMessages.length) return
+ setMessages(
+ applyGroupMemberMeta(
+ selectedContact,
+ mergeSyntheticMessages(selectedContact, cachedMessages.slice(-VIEW_MESSAGE_LIMIT))
+ )
)
- )
- })
+ })
setIsMessagesLoading(true)
- window.api.getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT }).then((nextMessages) => {
- setMessages(applyGroupMemberMeta(selectedContact, mergeSyntheticMessages(selectedContact, nextMessages)))
- setIsMessagesLoading(false)
- }).catch((error) => {
- console.warn('[Messages] date range load failed:', error)
- setIsMessagesLoading(false)
- })
+ window.api
+ .getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT })
+ .then((nextMessages) => {
+ setMessages(
+ applyGroupMemberMeta(
+ selectedContact,
+ mergeSyntheticMessages(selectedContact, nextMessages)
+ )
+ )
+ setIsMessagesLoading(false)
+ })
+ .catch((error) => {
+ console.warn('[Messages] date range load failed:', error)
+ setIsMessagesLoading(false)
+ })
}
}
@@ -786,10 +796,15 @@ function App(): React.ReactElement {
if (page === 'report') {
setReportWorkspaceView('result')
setSelectedReportId(null)
- setSelectedReportImageSize(null)
}
}
+ const openReport = (reportId: string): void => {
+ setSelectedReportId(reportId)
+ setReportWorkspaceView('result')
+ setActivePage('report')
+ }
+
const handleOpenReportWorkspace = (): void => {
if (!selectedContact) {
setReportNotice('请先选择一个群聊')
@@ -833,6 +848,8 @@ function App(): React.ReactElement {
const recordKey = `${reportGeneration.reportPaths.pngPath}:${reportGeneration.reportPaths.htmlPath}`
if (lastCapturedReportKeyRef.current === recordKey) return
lastCapturedReportKeyRef.current = recordKey
+ setLatestGeneratedReportId(null)
+ setIsSavingGeneratedReport(true)
const saveReport = async (): Promise => {
const result = await window.api.saveGeneratedReport({
@@ -849,10 +866,15 @@ function App(): React.ReactElement {
generatedAt: new Date().toISOString(),
generatedImage: reportGeneration.generatedImage || undefined,
htmlPath: reportGeneration.reportPaths?.htmlPath,
- pngPath: reportGeneration.reportPaths?.pngPath
+ pngPath: reportGeneration.reportPaths?.pngPath,
+ duration: reportGeneration.generationMetadata.durationMs,
+ modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model,
+ tokenUsage: reportGeneration.generationMetadata.tokenUsage,
+ generationLogs: reportGeneration.generationMetadata.generationLogs
})
if (!result.success || !result.record) {
+ setIsSavingGeneratedReport(false)
setReportNotice(result.error || '日报保存失败')
window.setTimeout(() => setReportNotice(''), 3200)
return
@@ -862,15 +884,16 @@ function App(): React.ReactElement {
result.record as GeneratedReportRecord,
...current.filter((report) => report.id !== result.record?.id)
])
- setSelectedReportId(result.record.id)
- setSelectedReportImageSize(null)
- setReportWorkspaceView('result')
- setActivePage('report')
+ setLatestGeneratedReportId(result.record.id)
+ setIsSavingGeneratedReport(false)
+ openReport(result.record.id)
}
void saveReport()
}, [
+ aiModelConfig.model,
reportGeneration.generatedImage,
+ reportGeneration.generationMetadata,
reportGeneration.phase,
reportGeneration.reportMessages.length,
reportGeneration.reportPaths,
@@ -878,10 +901,19 @@ function App(): React.ReactElement {
summaryDateRange
])
- const selectedReport =
- generatedReports.find((report) => report.id === selectedReportId) || null
+ const selectedReport = generatedReports.find((report) => report.id === selectedReportId) || null
const openReportResult = (): void => {
+ if (isSavingGeneratedReport) {
+ setReportNotice('日报正在保存,请稍候')
+ window.setTimeout(() => setReportNotice(''), 2400)
+ return
+ }
+ const targetReportId = latestGeneratedReportId || selectedReportId
+ if (targetReportId) {
+ openReport(targetReportId)
+ return
+ }
setReportWorkspaceView('result')
}
@@ -921,12 +953,14 @@ function App(): React.ReactElement {
setGeneratedReports((current) => current.filter((report) => report.id !== reportId))
if (selectedReportId === reportId) {
setSelectedReportId(null)
- setSelectedReportImageSize(null)
}
+ if (latestGeneratedReportId === reportId) setLatestGeneratedReportId(null)
return { success: true }
}
- const renderPlaceholderPage = (page: Exclude): React.ReactElement => {
+ const renderPlaceholderPage = (
+ page: Exclude
+ ): React.ReactElement => {
const labels: Record, string> = {
search: '检索',
export: '导出',
@@ -974,7 +1008,7 @@ function App(): React.ReactElement {
)
- const renderReportWorkspace = (): React.ReactElement => (
+ const renderReportWorkspace = (): React.ReactElement =>
reportWorkspaceView === 'result' ? (
{
- setSelectedReportId(reportId)
- setSelectedReportImageSize(null)
- }}
+ onSelectReport={openReport}
onCreateReport={openReportConfigure}
onDeleteReport={handleDeleteReport}
onOpenSettings={() => setShowSettings(true)}
@@ -997,13 +1028,8 @@ function App(): React.ReactElement {
onRegenerate={handleRegenerateReport}
onCopyImage={handleCopyReportImage}
onReveal={handleRevealReport}
- onImageSizeChange={setSelectedReportImageSize}
- />
-
+
) : (
@@ -1032,7 +1058,10 @@ function App(): React.ReactElement {
onSummaryDateRangeChange={setSummaryDateRange}
onSummaryMessageTypesChange={setSummaryMessageTypes}
onOpenModelSettings={() => setShowSettings(true)}
- onGenerate={() => void reportGeneration.generate()}
+ onGenerate={() => {
+ reportGeneration.resetGenerationStatus()
+ void reportGeneration.generate()
+ }}
onCloseResult={reportGeneration.closeResult}
onCopyImage={reportGeneration.copyImage}
onRevealReport={reportGeneration.revealReport}
@@ -1042,11 +1071,13 @@ function App(): React.ReactElement {
void reportGeneration.retry()}
+ onRetry={() => {
+ reportGeneration.resetGenerationStatus()
+ void reportGeneration.retry()
+ }}
/>
)
- )
const renderCurrentWorkspace = (): React.ReactElement => {
switch (activePage) {
@@ -1103,7 +1134,8 @@ function App(): React.ReactElement {
if (!isAuthenticated && bootState !== 'login') {
const title =
- startupProgress?.title || (bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...')
+ startupProgress?.title ||
+ (bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...')
const subtitle =
startupProgress?.subtitle ||
(bootState === 'connecting'
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 55481bc..470b50d 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -3541,6 +3541,7 @@ body {
.report-history-list {
flex: 1 1 auto;
min-height: 0;
+ overflow-x: hidden;
overflow-y: auto;
padding: 0 10px 12px;
}
@@ -3567,8 +3568,9 @@ body {
display: flex;
width: 100%;
min-width: 0;
+ box-sizing: border-box;
gap: 10px;
- padding: 10px 10px 10px 12px;
+ padding: 10px 54px 10px 12px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
@@ -3644,12 +3646,13 @@ body {
}
.report-history-delete {
+ position: absolute;
+ right: 10px;
+ top: 50%;
display: grid;
width: 38px;
height: 26px;
- flex: 0 0 auto;
place-items: center;
- align-self: center;
border: 1px solid rgba(198, 72, 72, 0.22);
border-radius: var(--wxex-radius-sm);
background: rgba(198, 72, 72, 0.06);
@@ -3657,6 +3660,7 @@ body {
cursor: pointer;
font: 600 12px/16px var(--wxex-font);
opacity: 0;
+ transform: translateY(-50%);
transition: opacity 0.12s ease;
}
@@ -3997,8 +4001,53 @@ body {
}
.report-export-list b {
+ min-width: 0;
color: var(--wxex-text-primary);
font-weight: 600;
+ overflow-wrap: anywhere;
+ text-align: right;
+}
+
+.report-generation-log {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.report-generation-log li {
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr);
+ gap: 8px;
+ color: var(--wxex-text-secondary);
+ font: 12px/18px var(--wxex-font);
+}
+
+.report-generation-log li > span {
+ color: var(--wxex-brand);
+ font-weight: 700;
+}
+
+.report-generation-log b,
+.report-generation-log time,
+.report-generation-log small {
+ display: block;
+}
+
+.report-generation-log b {
+ color: var(--wxex-text-primary);
+ font-weight: 600;
+}
+
+.report-generation-log time {
+ color: var(--wxex-text-muted);
+ font-size: 11px;
+}
+
+.report-generation-log small {
+ color: var(--wxex-text-secondary);
+ font: 600 11px/16px var(--wxex-font);
}
@media (max-width: 1120px) {
diff --git a/src/renderer/src/components/reports/ReportExportStatus.tsx b/src/renderer/src/components/reports/ReportExportStatus.tsx
index 4bee230..18aabd6 100644
--- a/src/renderer/src/components/reports/ReportExportStatus.tsx
+++ b/src/renderer/src/components/reports/ReportExportStatus.tsx
@@ -3,13 +3,11 @@ import type { GeneratedReportRecord } from './types'
interface ReportExportStatusProps {
report: GeneratedReportRecord | null
- imageSize: { width: number; height: number } | null
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
}
export function ReportExportStatus({
report,
- imageSize,
onReveal
}: ReportExportStatusProps): React.ReactElement {
const [status, setStatus] = React.useState('')
@@ -40,11 +38,11 @@ export function ReportExportStatus({
PNG 长图
{report.pngStatus === 'ready' ? '已保存' : '缺失'}
- {imageSize && (
+ {report.imageSize && (
图片尺寸
- {imageSize.width} x {imageSize.height}
+ {report.imageSize.width} x {report.imageSize.height}
)}
diff --git a/src/renderer/src/components/reports/ReportInfoPanel.tsx b/src/renderer/src/components/reports/ReportInfoPanel.tsx
new file mode 100644
index 0000000..c0210fc
--- /dev/null
+++ b/src/renderer/src/components/reports/ReportInfoPanel.tsx
@@ -0,0 +1,216 @@
+import React, { useState } from 'react'
+import type { GeneratedReportRecord } from './types'
+
+interface ReportInfoPanelProps {
+ report: GeneratedReportRecord | null
+ onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
+}
+
+const formatGeneratedAt = (value: string): string => {
+ const date = new Date(value)
+ if (!Number.isFinite(date.getTime())) return value
+ return date.toLocaleString('zh-CN', { hour12: false })
+}
+
+const formatDuration = (value?: number): string => {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '暂不可用'
+ return `${(value / 1000).toFixed(1)}s`
+}
+
+const formatFileSize = (value?: number): string => {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '暂不可用'
+ if (value < 1024) return `${value}B`
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`
+ return `${(value / 1024 / 1024).toFixed(1)}MB`
+}
+
+const formatImageSize = (report: GeneratedReportRecord): string => {
+ if (!report.imageSize) return '暂不可用'
+ return `${report.imageSize.width}×${report.imageSize.height}`
+}
+
+const formatToken = (value?: number): string =>
+ typeof value === 'number' && Number.isFinite(value) ? String(value) : '暂不可用'
+
+const modelLabel = (model?: string): string => {
+ if (!model) return '暂不可用'
+ if (model === 'deepseek-chat') return 'DeepSeek Chat'
+ if (model === 'gpt-4o-mini') return 'GPT-4o Mini'
+ if (model === 'gpt-4o') return 'GPT-4o'
+ if (model === 'gpt-4-turbo') return 'GPT-4 Turbo'
+ if (model === 'claude-3-5-sonnet-20240620') return 'Claude 3.5 Sonnet'
+ if (model === 'moonshot-v1-8k') return 'Moonshot V1'
+ return model
+}
+
+export function ReportInfoPanel({ report, onReveal }: ReportInfoPanelProps): React.ReactElement {
+ const [copyStatus, setCopyStatus] = useState('')
+ const [revealStatus, setRevealStatus] = useState('')
+ const path = report?.pngPath || report?.htmlPath || ''
+
+ const copyPath = async (): Promise => {
+ if (!path) return
+ try {
+ await navigator.clipboard.writeText(path)
+ setCopyStatus('文件路径已复制')
+ } catch (error) {
+ setCopyStatus(error instanceof Error ? error.message : String(error))
+ }
+ }
+
+ const revealReport = async (): Promise => {
+ if (!report) return
+ const result = await onReveal(report)
+ setRevealStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败')
+ }
+
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/reports/ReportSettingsPanel.tsx b/src/renderer/src/components/reports/ReportSettingsPanel.tsx
index 16ec186..4f1ea3e 100644
--- a/src/renderer/src/components/reports/ReportSettingsPanel.tsx
+++ b/src/renderer/src/components/reports/ReportSettingsPanel.tsx
@@ -4,7 +4,6 @@ import { ReportExportStatus } from './ReportExportStatus'
interface ReportSettingsPanelProps {
report: GeneratedReportRecord | null
- imageSize: { width: number; height: number } | null
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
}
@@ -16,7 +15,6 @@ const formatGeneratedAt = (value: string): string => {
export function ReportSettingsPanel({
report,
- imageSize,
onReveal
}: ReportSettingsPanelProps): React.ReactElement {
const [copyStatus, setCopyStatus] = useState('')
@@ -59,7 +57,7 @@ export function ReportSettingsPanel({
尚未选择报告。
)}
-
+
文件路径
{path ? (
diff --git a/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx b/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx
index 6cd4581..a33d84b 100644
--- a/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx
+++ b/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx
@@ -1,8 +1,5 @@
import React from 'react'
-import {
- REPORT_TASK_STEPS,
- ReportGenerationPhase
-} from '../../hooks/useGroupReportGeneration'
+import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
interface ReportTaskStatusPanelProps {
phase: ReportGenerationPhase
@@ -10,8 +7,18 @@ interface ReportTaskStatusPanelProps {
onRetry: () => void
}
+const TASK_STEPS: Array<{
+ id: Exclude
+ label: string
+}> = [
+ { id: 'loadingMessages', label: '读取聊天记录' },
+ { id: 'preparingInput', label: '整理日报输入' },
+ { id: 'requestingModel', label: '调用模型生成内容' },
+ { id: 'exportingReport', label: '导出 HTML 与 PNG' }
+]
+
const phaseIndex = (phase: ReportGenerationPhase): number =>
- REPORT_TASK_STEPS.findIndex((step) => step.id === phase)
+ TASK_STEPS.findIndex((step) => step.id === phase)
export function ReportTaskStatusPanel({
phase,
@@ -31,12 +38,12 @@ export function ReportTaskStatusPanel({
: phase === 'error'
? '生成失败'
: activeIndex >= 0
- ? `${activeIndex + 1}/${REPORT_TASK_STEPS.length}`
+ ? `${activeIndex + 1}/${TASK_STEPS.length}`
: '等待开始'}
- {REPORT_TASK_STEPS.map((step, index) => {
+ {TASK_STEPS.map((step, index) => {
const state =
completedAll || (activeIndex >= 0 && index < activeIndex)
? 'done'
diff --git a/src/renderer/src/components/reports/ReportViewer.tsx b/src/renderer/src/components/reports/ReportViewer.tsx
index fe8b415..916a7fd 100644
--- a/src/renderer/src/components/reports/ReportViewer.tsx
+++ b/src/renderer/src/components/reports/ReportViewer.tsx
@@ -11,7 +11,6 @@ interface ReportViewerProps {
onRegenerate: () => void
onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
- onImageSizeChange: (size: { width: number; height: number } | null) => void
}
export function ReportViewer({
@@ -20,8 +19,7 @@ export function ReportViewer({
onBackToConfigure,
onRegenerate,
onCopyImage,
- onReveal,
- onImageSizeChange
+ onReveal
}: ReportViewerProps): React.ReactElement {
const [zoom, setZoom] = useState(1)
const [status, setStatus] = useState('')
@@ -35,10 +33,9 @@ export function ReportViewer({
setImageError('')
setZoom(1)
setNaturalSize(null)
- onImageSizeChange(null)
})
return () => window.cancelAnimationFrame(frame)
- }, [onImageSizeChange, report?.id])
+ }, [report?.id])
const title = useMemo(
() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'),
@@ -111,16 +108,13 @@ export function ReportViewer({
}}
onLoad={(event) => {
const image = event.currentTarget
- const size = {
+ setNaturalSize({
width: image.naturalWidth,
height: image.naturalHeight
- }
- setNaturalSize(size)
- onImageSizeChange(size)
+ })
}}
onError={() => {
setImageError('日报图片加载失败')
- onImageSizeChange(null)
}}
/>
diff --git a/src/renderer/src/hooks/useGroupReportGeneration.ts b/src/renderer/src/hooks/useGroupReportGeneration.ts
index 2cc56e4..7cbca89 100644
--- a/src/renderer/src/hooks/useGroupReportGeneration.ts
+++ b/src/renderer/src/hooks/useGroupReportGeneration.ts
@@ -38,6 +38,25 @@ export interface ReportGenerationResult {
paths: ReportPaths
}
+export interface ReportGenerationLog {
+ label: string
+ startedAt: string
+ endedAt: string
+ duration: number
+}
+
+export interface ReportGenerationMetadata {
+ durationMs?: number
+ modelName?: string
+ tokenUsage?: {
+ input?: number
+ output?: number
+ total?: number
+ estimated?: boolean
+ }
+ generationLogs: ReportGenerationLog[]
+}
+
interface UseGroupReportGenerationArgs {
sourceContact: Contact | null
summaryDateRange: SummaryDateRange
@@ -62,7 +81,7 @@ export interface RangeMessageState {
error: string
}
-const withTimeout = async (promise: Promise, label: string): Promise => {
+const withTimeout = async (promise: Promise, label: string): Promise => {
let timer: number | undefined
const timeout = new Promise((_, reject) => {
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), REPORT_STEP_TIMEOUT_MS)
@@ -87,10 +106,25 @@ const selectedMessageTypeSet = (types: SummaryMessageType[]): Set =>
)
)
-const applyGroupMemberNames = async (
- contact: Contact,
- messages: Message[]
-): Promise => {
+const estimateTokenCount = (length: number): number => Math.max(1, Math.ceil(length / 1.6))
+
+const estimateTokenUsage = (
+ inputMessages: { role: string; content: string }[],
+ output: string
+): NonNullable => {
+ const inputLength = inputMessages.reduce((total, message) => total + message.content.length, 0)
+ const outputLength = output.replace(/\s+/g, '').length
+ const input = estimateTokenCount(inputLength)
+ const outputCount = estimateTokenCount(outputLength)
+ return {
+ input,
+ output: outputCount,
+ total: input + outputCount,
+ estimated: true
+ }
+}
+
+const applyGroupMemberNames = async (contact: Contact, messages: Message[]): Promise => {
let memberMap = new Map()
try {
const snapshot = await withTimeout(window.api.getGroupSnapshot(contact.md5), '读取群成员')
@@ -128,9 +162,11 @@ export function useGroupReportGeneration({
rangeState: RangeMessageState
generatedImage: string | null
reportPaths: ReportPaths | null
+ generationMetadata: ReportGenerationMetadata
isGenerating: boolean
generate: () => Promise
retry: () => Promise
+ resetGenerationStatus: () => void
clearError: () => void
closeResult: () => void
copyImage: () => Promise<{ success: boolean; error?: string }>
@@ -142,6 +178,9 @@ export function useGroupReportGeneration({
const [rangeState, setRangeState] = useState({ status: 'idle', error: '' })
const [generatedImage, setGeneratedImage] = useState(null)
const [reportPaths, setReportPaths] = useState(null)
+ const [generationMetadata, setGenerationMetadata] = useState({
+ generationLogs: []
+ })
const rangeRequestIdRef = useRef(0)
const isGenerating =
@@ -218,6 +257,14 @@ export function useGroupReportGeneration({
[allowedTypes, rangeMessages]
)
+ const resetGenerationStatus = useCallback((): void => {
+ setPhase('idle')
+ setError('')
+ setGeneratedImage(null)
+ setReportPaths(null)
+ setGenerationMetadata({ generationLogs: [] })
+ }, [])
+
const generate = useCallback(async (): Promise => {
if (isGenerating) return
if (!sourceContact) {
@@ -241,36 +288,66 @@ export function useGroupReportGeneration({
return
}
+ const startGenerateTime = Date.now()
+ const logs: ReportGenerationLog[] = []
+ const pushLog = (log: ReportGenerationLog): void => {
+ logs.push(log)
+ setGenerationMetadata({
+ 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 (label: string, task: () => Promise): Promise => {
+ const startedAt = new Date()
+ try {
+ return await task()
+ } finally {
+ pushLog(createStepLog(label, startedAt, new Date()))
+ }
+ }
+
setError('')
setGeneratedImage(null)
setReportPaths(null)
+ setGenerationMetadata({
+ modelName: modelConfig.model,
+ generationLogs: []
+ })
try {
- const sourceMessages =
- rangeState.status === 'success' ? rangeMessages : await loadRangeMessages(true)
- if (rangeState.status === 'success') setPhase('loadingMessages')
+ const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
setPhase('preparingInput')
- const namedReportMessages = await applyGroupMemberNames(sourceContact, filteredMessages)
- const input = buildGroupReportInput(namedReportMessages, sourceContact, true)
+ const input = await trackStep('整理输入', async () => {
+ const namedReportMessages = await applyGroupMemberNames(sourceContact, filteredMessages)
+ return buildGroupReportInput(namedReportMessages, sourceContact, true)
+ })
setPhase('requestingModel')
- const result = await withTimeout(
- window.api.aiChat(
- [
- { role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
- { role: 'user', content: input.prompt }
- ],
- modelConfig
- ),
- 'AI 生成日报'
+ const aiMessages = [
+ { role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
+ { role: 'user', content: input.prompt }
+ ]
+ const result = await trackStep('AI 生成', () =>
+ withTimeout(window.api.aiChat(aiMessages, modelConfig), 'AI 生成日报')
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
+ const tokenUsage =
+ result.usage && result.usage.total
+ ? result.usage
+ : estimateTokenUsage(aiMessages, result.data)
+
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
setPhase('exportingReport')
@@ -281,23 +358,39 @@ export function useGroupReportGeneration({
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')
} catch (generateError) {
setError(errorMessage(generateError))
setPhase('error')
}
- }, [
- isGenerating,
- loadRangeMessages,
- modelConfig,
- rangeMessages,
- rangeState.status,
- sourceContact,
- summaryMessageTypes
- ])
+ }, [isGenerating, loadRangeMessages, modelConfig, sourceContact, summaryMessageTypes])
const clearError = useCallback((): void => {
setError('')
@@ -327,9 +420,11 @@ export function useGroupReportGeneration({
rangeState,
generatedImage,
reportPaths,
+ generationMetadata,
isGenerating,
generate,
retry: generate,
+ resetGenerationStatus,
clearError,
closeResult,
copyImage,
diff --git a/src/renderer/src/utils/group-report.ts b/src/renderer/src/utils/group-report.ts
index 74389f4..ed3f251 100644
--- a/src/renderer/src/utils/group-report.ts
+++ b/src/renderer/src/utils/group-report.ts
@@ -12,7 +12,14 @@ import {
} from '../../../shared/group-report'
export type SummaryDateRange = 'today' | 'yesterday' | '7days'
-export type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system'
+export type SummaryMessageType =
+ | 'text'
+ | 'image'
+ | 'sticker'
+ | 'video'
+ | 'voice'
+ | 'share'
+ | 'system'
export const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
{ value: 'today', label: '今天' },
@@ -319,6 +326,56 @@ const normalizeHeat = (value: unknown): ReportHeat => {
return '中'
}
+const repairJsonLikeText = (value: string): string => {
+ let repaired = ''
+ let inString = false
+ let escaped = false
+
+ for (let index = 0; index < value.length; index += 1) {
+ const char = value[index]
+
+ if (!inString) {
+ if (char === '"') inString = true
+ repaired += char
+ continue
+ }
+
+ if (escaped) {
+ repaired += char
+ escaped = false
+ continue
+ }
+
+ if (char === '\\') {
+ repaired += char
+ escaped = true
+ continue
+ }
+
+ if (char === '\n') {
+ repaired += '\\n'
+ continue
+ }
+
+ if (char === '\r') {
+ continue
+ }
+
+ if (char === '"') {
+ const nextNonSpace = value.slice(index + 1).match(/\S/)?.[0] || ''
+ if (nextNonSpace && ![',', '}', ']', ':'].includes(nextNonSpace)) {
+ repaired += '\\"'
+ continue
+ }
+ inString = false
+ }
+
+ repaired += char
+ }
+
+ return repaired.replace(/,\s*([}\]])/g, '$1')
+}
+
const extractJson = (raw: string): unknown => {
const cleaned = raw
.trim()
@@ -327,7 +384,16 @@ const extractJson = (raw: string): unknown => {
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))
+ const jsonText = cleaned.slice(start, end + 1)
+ try {
+ return JSON.parse(jsonText)
+ } catch (error) {
+ try {
+ return JSON.parse(repairJsonLikeText(jsonText))
+ } catch {
+ throw error
+ }
+ }
}
export const parseGroupDailyReport = (
diff --git a/src/shared/group-report.ts b/src/shared/group-report.ts
index 6370b79..f1119b6 100644
--- a/src/shared/group-report.ts
+++ b/src/shared/group-report.ts
@@ -95,6 +95,18 @@ export interface GroupReportExportResult {
htmlPath?: string
pngPath?: string
imageDataUrl?: string
+ exportTimings?: {
+ html?: {
+ startedAt: string
+ endedAt: string
+ duration: number
+ }
+ png?: {
+ startedAt: string
+ endedAt: string
+ duration: number
+ }
+ }
warnings?: string[]
error?: string
}
diff --git a/src/shared/report-history.ts b/src/shared/report-history.ts
index 352ade1..7e743a9 100644
--- a/src/shared/report-history.ts
+++ b/src/shared/report-history.ts
@@ -15,6 +15,28 @@ export interface GeneratedReportRecord {
htmlStatus: ReportAssetStatus
pngStatus: ReportAssetStatus
generatedImage?: string
+ imageSize?: {
+ width: number
+ height: number
+ }
+ duration?: number
+ modelName?: string
+ tokenUsage?: {
+ input?: number
+ output?: number
+ total?: number
+ estimated?: boolean
+ }
+ fileSize?: {
+ html?: number
+ png?: number
+ }
+ generationLogs?: {
+ label: string
+ startedAt: string
+ endedAt: string
+ duration: number
+ }[]
}
export interface SaveGeneratedReportRequest {
@@ -27,6 +49,20 @@ export interface SaveGeneratedReportRequest {
generatedImage?: string
htmlPath?: string
pngPath?: string
+ duration?: number
+ modelName?: string
+ tokenUsage?: {
+ input?: number
+ output?: number
+ total?: number
+ estimated?: boolean
+ }
+ generationLogs?: {
+ label: string
+ startedAt: string
+ endedAt: string
+ duration: number
+ }[]
}
export interface ReportHistoryResult {