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 {