fix: 完善 AI 日报详情状态与解析容错

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
committed by 电摇小子
parent d28b579cb4
commit 519c10223d
15 changed files with 777 additions and 197 deletions
+17 -3
View File
@@ -106,9 +106,7 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<vo
const snapshot = getGroupSnapshot(resolved.md5) const snapshot = getGroupSnapshot(resolved.md5)
if (!snapshot) { if (!snapshot) {
metadata.warnings = metadata.warnings ?? [] metadata.warnings = metadata.warnings ?? []
metadata.warnings.push( metadata.warnings.push(`enrich skipped: group snapshot not available for "${metadata.talker}"`)
`enrich skipped: group snapshot not available for "${metadata.talker}"`
)
return return
} }
@@ -331,14 +329,30 @@ export const exportGroupReport = async (
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图` const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
const htmlPath = path.join(outputDir, `${baseName}.html`) const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`) const pngPath = path.join(outputDir, `${baseName}.png`)
const htmlStartedAt = new Date()
const html = await renderReportHtml(request) const html = await renderReportHtml(request)
await fs.writeFile(htmlPath, html, 'utf8') await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(htmlPath, pngPath) const imageDataUrl = await captureFullPage(htmlPath, pngPath)
const pngEndedAt = new Date()
return { return {
success: true, success: true,
htmlPath, htmlPath,
pngPath, pngPath,
imageDataUrl, imageDataUrl,
exportTimings: {
html: {
startedAt: htmlStartedAt.toISOString(),
endedAt: htmlEndedAt.toISOString(),
duration: htmlEndedAt.getTime() - htmlStartedAt.getTime()
},
png: {
startedAt: pngStartedAt.toISOString(),
endedAt: pngEndedAt.toISOString(),
duration: pngEndedAt.getTime() - pngStartedAt.getTime()
}
},
warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined
} }
} catch (error) { } catch (error) {
+27 -19
View File
@@ -21,15 +21,8 @@ import { DatabaseKeyStore } from './database-key-store'
import { KeyServiceMac } from './key-service-mac' import { KeyServiceMac } from './key-service-mac'
import { KeyService as KeyServiceWin } from './key-service-win' import { KeyService as KeyServiceWin } from './key-service-win'
import * as chat from './services/chat-service' import * as chat from './services/chat-service'
import { import { apiServer } from './http-server'
apiServer import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
} from './http-server'
import {
loadSettings,
saveSettings,
getSettingsPath,
AppSettings
} from './services/settings-store'
import { import {
getBootstrapCache, getBootstrapCache,
getCachedMessages, getCachedMessages,
@@ -57,7 +50,8 @@ let tray: Tray | null = null
// WCDB's Windows runtime checks the host application name during wcdb_init. // WCDB's Windows runtime checks the host application name during wcdb_init.
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows. // Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer') app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null = null let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
null
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03' const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
const TRAY_MODE = const TRAY_MODE =
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1' 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.slice(2), 16)
: Number.parseInt(raw, 10) : Number.parseInt(raw, 10)
if (!Number.isFinite(parsed)) return raw 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 } { function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
@@ -80,7 +77,6 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
} }
} }
function createWindow(): void { function createWindow(): void {
// 鍒涘缓娴忚鍣ㄧ獥鍙? // 鍒涘缓娴忚鍣ㄧ獥鍙?
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
@@ -191,7 +187,9 @@ app.whenReady().then(async () => {
return databaseKeyStore.save(clipboardKey) 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()) ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
@@ -258,7 +256,9 @@ app.whenReady().then(async () => {
ipcMain.handle('db:getContacts', (_, filter?: string) => { ipcMain.handle('db:getContacts', (_, filter?: string) => {
const accountRoot = chat.getCurrentAccountRoot() 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) { if (!filter && chat.isReady() && accountRoot) {
saveBootstrapContacts(accountRoot, contacts) saveBootstrapContacts(accountRoot, contacts)
} }
@@ -316,7 +316,18 @@ app.whenReady().then(async () => {
messages: messages as any, messages: messages as any,
model: model 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) { } catch (error: unknown) {
console.error('AI API Error:', error) console.error('AI API Error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error' const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -441,10 +452,7 @@ app.whenReady().then(async () => {
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => { ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
const before = loadSettings() const before = loadSettings()
const merged = saveSettings({ ...before, ...patch }) const merged = saveSettings({ ...before, ...patch })
if ( if (before.imageXorKey !== merged.imageXorKey || before.imageAesKey !== merged.imageAesKey) {
before.imageXorKey !== merged.imageXorKey ||
before.imageAesKey !== merged.imageAesKey
) {
imageDecryptService = null imageDecryptService = null
} }
return { settings: merged, settingsPath: getSettingsPath() } return { settings: merged, settingsPath: getSettingsPath() }
+45 -8
View File
@@ -1,4 +1,4 @@
import { app } from 'electron' import { app, nativeImage } from 'electron'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import type { import type {
@@ -50,6 +50,26 @@ const readPngAsDataUrl = async (filePath?: string): Promise<string | undefined>
return `data:image/png;base64,${content.toString('base64')}` 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<number | undefined> => {
if (!filePath) return undefined
try {
const stat = await fs.stat(filePath)
return stat.isFile() ? stat.size : undefined
} catch {
return undefined
}
}
const normalizeRecord = async ( const normalizeRecord = async (
record: GeneratedReportRecord, record: GeneratedReportRecord,
jsonPath: string jsonPath: string
@@ -61,7 +81,12 @@ const normalizeRecord = async (
jsonPath, jsonPath,
htmlStatus, htmlStatus,
pngStatus, 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<SaveGeneratedReportResult> { ): Promise<SaveGeneratedReportResult> {
try { try {
const generatedAtDate = new Date(request.generatedAt) const generatedAtDate = new Date(request.generatedAt)
const timestamp = Number.isFinite(generatedAtDate.getTime()) const timestamp = Number.isFinite(generatedAtDate.getTime()) ? generatedAtDate : new Date()
? generatedAtDate
: new Date()
const year = String(timestamp.getFullYear()) const year = String(timestamp.getFullYear())
const month = pad2(timestamp.getMonth() + 1) const month = pad2(timestamp.getMonth() + 1)
const directory = path.join(getReportsRoot(), year, month) const directory = path.join(getReportsRoot(), year, month)
@@ -152,7 +175,16 @@ export async function saveGeneratedReport(
pngPath: savedPngPath, pngPath: savedPngPath,
jsonPath, jsonPath,
htmlStatus: savedHtmlPath ? 'ready' : 'missing', 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') 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<DeleteGeneratedReportResult> { export async function deleteGeneratedReport(
reportId: string
): Promise<DeleteGeneratedReportResult> {
try { try {
const jsonFiles = await walkJsonFiles(getReportsRoot()) const jsonFiles = await walkJsonFiles(getReportsRoot())
for (const jsonPath of jsonFiles) { for (const jsonPath of jsonFiles) {
@@ -191,7 +225,10 @@ export async function deleteGeneratedReport(reportId: string): Promise<DeleteGen
) )
return { success: true, deletedId: reportId } return { success: true, deletedId: reportId }
} catch (error) { } catch (error) {
console.warn(`[ReportHistory] skip invalid report record while deleting: ${jsonPath}`, error) console.warn(
`[ReportHistory] skip invalid report record while deleting: ${jsonPath}`,
error
)
} }
} }
return { success: false, error: '未找到要删除的日报记录' } return { success: false, error: '未找到要删除的日报记录' }
+22 -4
View File
@@ -70,7 +70,17 @@ declare global {
aiChat: ( aiChat: (
messages: { role: string; content: string }[], messages: { role: string; content: string }[],
options?: { apiKey?: string; model?: string; baseURL?: string } options?: { apiKey?: string; model?: string; baseURL?: string }
) => 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 }> copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
getVoiceData: ( getVoiceData: (
sessionId: string, sessionId: string,
@@ -84,7 +94,13 @@ declare global {
imageDatNameOrThumb?: string | boolean, imageDatNameOrThumb?: string | boolean,
sessionId?: string, sessionId?: string,
options?: { force?: boolean } 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: ( getSticker: (
cdnUrl?: string, cdnUrl?: string,
md5?: string md5?: string
@@ -141,7 +157,8 @@ declare global {
} }
settingsPath: string settingsPath: string
}> }>
setSettings: (patch: Partial<{ setSettings: (
patch: Partial<{
dbRoot: string dbRoot: string
apiEnabled: boolean apiEnabled: boolean
apiHost: string apiHost: string
@@ -149,7 +166,8 @@ declare global {
imageKeyRoot: string imageKeyRoot: string
imageXorKey: string imageXorKey: string
imageAesKey: string imageAesKey: string
}>) => Promise<{ }>
) => Promise<{
settings: { settings: {
dbRoot: string dbRoot: string
apiEnabled: boolean apiEnabled: boolean
+87 -55
View File
@@ -6,16 +6,13 @@ import { AppShell } from './components/layout/AppShell'
import { AppPage } from './components/layout/navigation' import { AppPage } from './components/layout/navigation'
import { AiReportWorkspace } from './components/reports/AiReportWorkspace' import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar' 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 { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel' import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
import { ReportViewer } from './components/reports/ReportViewer' import { ReportViewer } from './components/reports/ReportViewer'
import { contactDisplayName } from './components/reports/types' import { contactDisplayName } from './components/reports/types'
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types' import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
import { import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
AiModelConfig,
useGroupReportGeneration
} from './hooks/useGroupReportGeneration'
import { SummaryDateRange, SummaryMessageType } from './utils/group-report' import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types' import { Contact, Message } from '../../shared/types'
@@ -33,14 +30,7 @@ function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
<circle <circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
cx="12"
cy="12"
r="3"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
/>
{hidden && ( {hidden && (
<path <path
d="M4 4l16 16" d="M4 4l16 16"
@@ -65,7 +55,9 @@ const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-K
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000 const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
const VIEW_MESSAGE_LIMIT = 600 const VIEW_MESSAGE_LIMIT = 600
const AUTO_LOGIN_ENABLED = ['1', 'true', 'yes', 'on'].includes( const AUTO_LOGIN_ENABLED = ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '').trim().toLowerCase() String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
.toLowerCase()
) )
const getMessageIdentity = (message: Message): string => { const getMessageIdentity = (message: Message): string => {
@@ -175,10 +167,8 @@ function App(): React.ReactElement {
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result') const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([]) const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
const [selectedReportId, setSelectedReportId] = useState<string | null>(null) const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
const [selectedReportImageSize, setSelectedReportImageSize] = useState<{ const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
width: number const [isSavingGeneratedReport, setIsSavingGeneratedReport] = useState(false)
height: number
} | null>(null)
const [reportNotice, setReportNotice] = useState('') const [reportNotice, setReportNotice] = useState('')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today') const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text']) const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
@@ -225,8 +215,7 @@ function App(): React.ReactElement {
} }
}, []) }, [])
const waitForPaint = (): Promise<void> => const waitForPaint = (): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, 80))
new Promise((resolve) => window.setTimeout(resolve, 80))
const refreshSelfInfo = async (): Promise<void> => { const refreshSelfInfo = async (): Promise<void> => {
try { try {
@@ -317,10 +306,18 @@ function App(): React.ReactElement {
) )
if (Object.keys(avatars).length === 0) continue if (Object.keys(avatars).length === 0) continue
setContacts((current) => 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) => 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) { } catch (error) {
console.warn('[Contacts] avatar hydrate failed:', error) console.warn('[Contacts] avatar hydrate failed:', error)
@@ -375,9 +372,7 @@ function App(): React.ReactElement {
void refreshSelfInfo() void refreshSelfInfo()
} else { } else {
const error = typeof result === 'boolean' ? '' : result.error const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus( setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
`自动连接失败,请重新输入${error ? `: ${error}` : ''}`
)
setDbKeyStatusKind('error') setDbKeyStatusKind('error')
setBootState('login') setBootState('login')
} }
@@ -399,6 +394,8 @@ function App(): React.ReactElement {
active = false active = false
unsubscribe() unsubscribe()
} }
// 自动连接只在应用启动时执行一次,避免联系人加载刷新触发重复连接。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
@@ -658,7 +655,10 @@ function App(): React.ReactElement {
if (selectedContactMd5Ref.current !== contact.md5) return if (selectedContactMd5Ref.current !== contact.md5) return
if (!snapshot) return if (!snapshot) return
setMessages((current) => setMessages((current) =>
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId)) applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, current, snapshot.roomId)
)
) )
}) })
}, 120) }, 120)
@@ -672,7 +672,9 @@ function App(): React.ReactElement {
setDateRange(range) setDateRange(range)
if (selectedContact) { if (selectedContact) {
const { startTime, endTime } = getDateRangeParams(range) const { startTime, endTime } = getDateRangeParams(range)
window.api.getCachedMessages(selectedContact.md5, startTime, endTime).then((cachedMessages) => { window.api
.getCachedMessages(selectedContact.md5, startTime, endTime)
.then((cachedMessages) => {
if (!cachedMessages.length) return if (!cachedMessages.length) return
setMessages( setMessages(
applyGroupMemberMeta( applyGroupMemberMeta(
@@ -682,10 +684,18 @@ function App(): React.ReactElement {
) )
}) })
setIsMessagesLoading(true) setIsMessagesLoading(true)
window.api.getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT }).then((nextMessages) => { window.api
setMessages(applyGroupMemberMeta(selectedContact, mergeSyntheticMessages(selectedContact, nextMessages))) .getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT })
.then((nextMessages) => {
setMessages(
applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, nextMessages)
)
)
setIsMessagesLoading(false) setIsMessagesLoading(false)
}).catch((error) => { })
.catch((error) => {
console.warn('[Messages] date range load failed:', error) console.warn('[Messages] date range load failed:', error)
setIsMessagesLoading(false) setIsMessagesLoading(false)
}) })
@@ -786,10 +796,15 @@ function App(): React.ReactElement {
if (page === 'report') { if (page === 'report') {
setReportWorkspaceView('result') setReportWorkspaceView('result')
setSelectedReportId(null) setSelectedReportId(null)
setSelectedReportImageSize(null)
} }
} }
const openReport = (reportId: string): void => {
setSelectedReportId(reportId)
setReportWorkspaceView('result')
setActivePage('report')
}
const handleOpenReportWorkspace = (): void => { const handleOpenReportWorkspace = (): void => {
if (!selectedContact) { if (!selectedContact) {
setReportNotice('请先选择一个群聊') setReportNotice('请先选择一个群聊')
@@ -833,6 +848,8 @@ function App(): React.ReactElement {
const recordKey = `${reportGeneration.reportPaths.pngPath}:${reportGeneration.reportPaths.htmlPath}` const recordKey = `${reportGeneration.reportPaths.pngPath}:${reportGeneration.reportPaths.htmlPath}`
if (lastCapturedReportKeyRef.current === recordKey) return if (lastCapturedReportKeyRef.current === recordKey) return
lastCapturedReportKeyRef.current = recordKey lastCapturedReportKeyRef.current = recordKey
setLatestGeneratedReportId(null)
setIsSavingGeneratedReport(true)
const saveReport = async (): Promise<void> => { const saveReport = async (): Promise<void> => {
const result = await window.api.saveGeneratedReport({ const result = await window.api.saveGeneratedReport({
@@ -849,10 +866,15 @@ function App(): React.ReactElement {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
generatedImage: reportGeneration.generatedImage || undefined, generatedImage: reportGeneration.generatedImage || undefined,
htmlPath: reportGeneration.reportPaths?.htmlPath, 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) { if (!result.success || !result.record) {
setIsSavingGeneratedReport(false)
setReportNotice(result.error || '日报保存失败') setReportNotice(result.error || '日报保存失败')
window.setTimeout(() => setReportNotice(''), 3200) window.setTimeout(() => setReportNotice(''), 3200)
return return
@@ -862,15 +884,16 @@ function App(): React.ReactElement {
result.record as GeneratedReportRecord, result.record as GeneratedReportRecord,
...current.filter((report) => report.id !== result.record?.id) ...current.filter((report) => report.id !== result.record?.id)
]) ])
setSelectedReportId(result.record.id) setLatestGeneratedReportId(result.record.id)
setSelectedReportImageSize(null) setIsSavingGeneratedReport(false)
setReportWorkspaceView('result') openReport(result.record.id)
setActivePage('report')
} }
void saveReport() void saveReport()
}, [ }, [
aiModelConfig.model,
reportGeneration.generatedImage, reportGeneration.generatedImage,
reportGeneration.generationMetadata,
reportGeneration.phase, reportGeneration.phase,
reportGeneration.reportMessages.length, reportGeneration.reportMessages.length,
reportGeneration.reportPaths, reportGeneration.reportPaths,
@@ -878,10 +901,19 @@ function App(): React.ReactElement {
summaryDateRange summaryDateRange
]) ])
const selectedReport = const selectedReport = generatedReports.find((report) => report.id === selectedReportId) || null
generatedReports.find((report) => report.id === selectedReportId) || null
const openReportResult = (): void => { const openReportResult = (): void => {
if (isSavingGeneratedReport) {
setReportNotice('日报正在保存,请稍候')
window.setTimeout(() => setReportNotice(''), 2400)
return
}
const targetReportId = latestGeneratedReportId || selectedReportId
if (targetReportId) {
openReport(targetReportId)
return
}
setReportWorkspaceView('result') setReportWorkspaceView('result')
} }
@@ -921,12 +953,14 @@ function App(): React.ReactElement {
setGeneratedReports((current) => current.filter((report) => report.id !== reportId)) setGeneratedReports((current) => current.filter((report) => report.id !== reportId))
if (selectedReportId === reportId) { if (selectedReportId === reportId) {
setSelectedReportId(null) setSelectedReportId(null)
setSelectedReportImageSize(null)
} }
if (latestGeneratedReportId === reportId) setLatestGeneratedReportId(null)
return { success: true } return { success: true }
} }
const renderPlaceholderPage = (page: Exclude<AppPage, 'archive' | 'report'>): React.ReactElement => { const renderPlaceholderPage = (
page: Exclude<AppPage, 'archive' | 'report'>
): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = { const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
search: '检索', search: '检索',
export: '导出', export: '导出',
@@ -974,7 +1008,7 @@ function App(): React.ReactElement {
</div> </div>
) )
const renderReportWorkspace = (): React.ReactElement => ( const renderReportWorkspace = (): React.ReactElement =>
reportWorkspaceView === 'result' ? ( reportWorkspaceView === 'result' ? (
<div className="report-center-page"> <div className="report-center-page">
<ReportHistorySidebar <ReportHistorySidebar
@@ -982,10 +1016,7 @@ function App(): React.ReactElement {
selectedReportId={selectedReportId} selectedReportId={selectedReportId}
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={isAuthenticated} dbReady={isAuthenticated}
onSelectReport={(reportId) => { onSelectReport={openReport}
setSelectedReportId(reportId)
setSelectedReportImageSize(null)
}}
onCreateReport={openReportConfigure} onCreateReport={openReportConfigure}
onDeleteReport={handleDeleteReport} onDeleteReport={handleDeleteReport}
onOpenSettings={() => setShowSettings(true)} onOpenSettings={() => setShowSettings(true)}
@@ -997,13 +1028,8 @@ function App(): React.ReactElement {
onRegenerate={handleRegenerateReport} onRegenerate={handleRegenerateReport}
onCopyImage={handleCopyReportImage} onCopyImage={handleCopyReportImage}
onReveal={handleRevealReport} onReveal={handleRevealReport}
onImageSizeChange={setSelectedReportImageSize}
/>
<ReportSettingsPanel
report={selectedReport}
imageSize={selectedReportImageSize}
onReveal={handleRevealReport}
/> />
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
</div> </div>
) : ( ) : (
<div className="report-page"> <div className="report-page">
@@ -1032,7 +1058,10 @@ function App(): React.ReactElement {
onSummaryDateRangeChange={setSummaryDateRange} onSummaryDateRangeChange={setSummaryDateRange}
onSummaryMessageTypesChange={setSummaryMessageTypes} onSummaryMessageTypesChange={setSummaryMessageTypes}
onOpenModelSettings={() => setShowSettings(true)} onOpenModelSettings={() => setShowSettings(true)}
onGenerate={() => void reportGeneration.generate()} onGenerate={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.generate()
}}
onCloseResult={reportGeneration.closeResult} onCloseResult={reportGeneration.closeResult}
onCopyImage={reportGeneration.copyImage} onCopyImage={reportGeneration.copyImage}
onRevealReport={reportGeneration.revealReport} onRevealReport={reportGeneration.revealReport}
@@ -1042,11 +1071,13 @@ function App(): React.ReactElement {
<ReportTaskStatusPanel <ReportTaskStatusPanel
phase={reportGeneration.phase} phase={reportGeneration.phase}
error={reportGeneration.error} error={reportGeneration.error}
onRetry={() => void reportGeneration.retry()} onRetry={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.retry()
}}
/> />
</div> </div>
) )
)
const renderCurrentWorkspace = (): React.ReactElement => { const renderCurrentWorkspace = (): React.ReactElement => {
switch (activePage) { switch (activePage) {
@@ -1103,7 +1134,8 @@ function App(): React.ReactElement {
if (!isAuthenticated && bootState !== 'login') { if (!isAuthenticated && bootState !== 'login') {
const title = const title =
startupProgress?.title || (bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...') startupProgress?.title ||
(bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...')
const subtitle = const subtitle =
startupProgress?.subtitle || startupProgress?.subtitle ||
(bootState === 'connecting' (bootState === 'connecting'
+52 -3
View File
@@ -3541,6 +3541,7 @@ body {
.report-history-list { .report-history-list {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding: 0 10px 12px; padding: 0 10px 12px;
} }
@@ -3567,8 +3568,9 @@ body {
display: flex; display: flex;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
box-sizing: border-box;
gap: 10px; gap: 10px;
padding: 10px 10px 10px 12px; padding: 10px 54px 10px 12px;
border: 0; border: 0;
border-radius: var(--wxex-radius-md); border-radius: var(--wxex-radius-md);
background: transparent; background: transparent;
@@ -3644,12 +3646,13 @@ body {
} }
.report-history-delete { .report-history-delete {
position: absolute;
right: 10px;
top: 50%;
display: grid; display: grid;
width: 38px; width: 38px;
height: 26px; height: 26px;
flex: 0 0 auto;
place-items: center; place-items: center;
align-self: center;
border: 1px solid rgba(198, 72, 72, 0.22); border: 1px solid rgba(198, 72, 72, 0.22);
border-radius: var(--wxex-radius-sm); border-radius: var(--wxex-radius-sm);
background: rgba(198, 72, 72, 0.06); background: rgba(198, 72, 72, 0.06);
@@ -3657,6 +3660,7 @@ body {
cursor: pointer; cursor: pointer;
font: 600 12px/16px var(--wxex-font); font: 600 12px/16px var(--wxex-font);
opacity: 0; opacity: 0;
transform: translateY(-50%);
transition: opacity 0.12s ease; transition: opacity 0.12s ease;
} }
@@ -3997,8 +4001,53 @@ body {
} }
.report-export-list b { .report-export-list b {
min-width: 0;
color: var(--wxex-text-primary); color: var(--wxex-text-primary);
font-weight: 600; 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) { @media (max-width: 1120px) {
@@ -3,13 +3,11 @@ import type { GeneratedReportRecord } from './types'
interface ReportExportStatusProps { interface ReportExportStatusProps {
report: GeneratedReportRecord | null report: GeneratedReportRecord | null
imageSize: { width: number; height: number } | null
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }> onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
} }
export function ReportExportStatus({ export function ReportExportStatus({
report, report,
imageSize,
onReveal onReveal
}: ReportExportStatusProps): React.ReactElement { }: ReportExportStatusProps): React.ReactElement {
const [status, setStatus] = React.useState('') const [status, setStatus] = React.useState('')
@@ -40,11 +38,11 @@ export function ReportExportStatus({
<span>PNG </span> <span>PNG </span>
<b>{report.pngStatus === 'ready' ? '已保存' : '缺失'}</b> <b>{report.pngStatus === 'ready' ? '已保存' : '缺失'}</b>
</div> </div>
{imageSize && ( {report.imageSize && (
<div> <div>
<span></span> <span></span>
<b> <b>
{imageSize.width} x {imageSize.height} {report.imageSize.width} x {report.imageSize.height}
</b> </b>
</div> </div>
)} )}
@@ -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<void> => {
if (!path) return
try {
await navigator.clipboard.writeText(path)
setCopyStatus('文件路径已复制')
} catch (error) {
setCopyStatus(error instanceof Error ? error.message : String(error))
}
}
const revealReport = async (): Promise<void> => {
if (!report) return
const result = await onReveal(report)
setRevealStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败')
}
return (
<aside className="report-settings-panel report-info-panel">
<header>
<h2></h2>
<p></p>
</header>
{!report ? (
<section className="report-settings-section">
<h3></h3>
<p></p>
</section>
) : (
<>
<section className="report-settings-section">
<h3></h3>
<div className="report-export-list">
<div>
<span></span>
<b>{formatGeneratedAt(report.generatedAt)}</b>
</div>
<div>
<span></span>
<b>{report.messageCount} </b>
</div>
<div>
<span></span>
<b>{report.dateRange}</b>
</div>
<div>
<span></span>
<b>{report.contactName}</b>
</div>
</div>
</section>
<section className="report-settings-section">
<h3>AI </h3>
<div className="report-export-list">
<div>
<span></span>
<b>{formatDuration(report.duration)}</b>
</div>
<div>
<span></span>
<b>{modelLabel(report.modelName)}</b>
</div>
<div>
<span>Token </span>
<b>
{report.tokenUsage?.estimated
? '估算'
: report.tokenUsage
? 'API 返回'
: '暂不可用'}
</b>
</div>
<div>
<span>Token </span>
<b>{formatToken(report.tokenUsage?.input)}</b>
</div>
<div>
<span>Token </span>
<b>{formatToken(report.tokenUsage?.output)}</b>
</div>
<div>
<span>Token </span>
<b>{formatToken(report.tokenUsage?.total)}</b>
</div>
</div>
</section>
<section className="report-settings-section">
<h3></h3>
<div className="report-export-list">
<div>
<span>PNG </span>
<b>{formatImageSize(report)}</b>
</div>
<div>
<span>PNG </span>
<b>{formatFileSize(report.fileSize?.png)}</b>
</div>
<div>
<span>HTML </span>
<b>{formatFileSize(report.fileSize?.html)}</b>
</div>
<div>
<span>HTML </span>
<b>{report.htmlStatus === 'ready' ? '已保存' : '缺失'}</b>
</div>
<div>
<span>PNG </span>
<b>{report.pngStatus === 'ready' ? '已保存' : '缺失'}</b>
</div>
</div>
{(report.pngPath || report.htmlPath) && (
<button type="button" onClick={() => void revealReport()}>
</button>
)}
{revealStatus && <p>{revealStatus}</p>}
</section>
<section className="report-settings-section">
<h3></h3>
{report.generationLogs?.length ? (
<ol className="report-generation-log">
{report.generationLogs.map((item) => (
<li key={`${item.startedAt}-${item.label}`}>
<span></span>
<div>
<b>{item.label}</b>
<time>
{formatGeneratedAt(item.startedAt)} - {formatGeneratedAt(item.endedAt)}
</time>
<small>{formatDuration(item.duration)}</small>
</div>
</li>
))}
</ol>
) : (
<p></p>
)}
</section>
<section className="report-settings-section">
<h3></h3>
{path ? (
<>
<code>{path}</code>
<button type="button" onClick={() => void copyPath()}>
</button>
{copyStatus && <p>{copyStatus}</p>}
</>
) : (
<p></p>
)}
</section>
</>
)}
<section className="report-settings-section muted">
<h3></h3>
<p></p>
</section>
</aside>
)
}
@@ -4,7 +4,6 @@ import { ReportExportStatus } from './ReportExportStatus'
interface ReportSettingsPanelProps { interface ReportSettingsPanelProps {
report: GeneratedReportRecord | null report: GeneratedReportRecord | null
imageSize: { width: number; height: number } | null
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }> onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
} }
@@ -16,7 +15,6 @@ const formatGeneratedAt = (value: string): string => {
export function ReportSettingsPanel({ export function ReportSettingsPanel({
report, report,
imageSize,
onReveal onReveal
}: ReportSettingsPanelProps): React.ReactElement { }: ReportSettingsPanelProps): React.ReactElement {
const [copyStatus, setCopyStatus] = useState('') const [copyStatus, setCopyStatus] = useState('')
@@ -59,7 +57,7 @@ export function ReportSettingsPanel({
<p></p> <p></p>
)} )}
</section> </section>
<ReportExportStatus report={report} imageSize={imageSize} onReveal={onReveal} /> <ReportExportStatus report={report} onReveal={onReveal} />
<section className="report-settings-section"> <section className="report-settings-section">
<h3></h3> <h3></h3>
{path ? ( {path ? (
@@ -1,8 +1,5 @@
import React from 'react' import React from 'react'
import { import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
REPORT_TASK_STEPS,
ReportGenerationPhase
} from '../../hooks/useGroupReportGeneration'
interface ReportTaskStatusPanelProps { interface ReportTaskStatusPanelProps {
phase: ReportGenerationPhase phase: ReportGenerationPhase
@@ -10,8 +7,18 @@ interface ReportTaskStatusPanelProps {
onRetry: () => void onRetry: () => void
} }
const TASK_STEPS: Array<{
id: Exclude<ReportGenerationPhase, 'idle' | 'success' | 'error'>
label: string
}> = [
{ id: 'loadingMessages', label: '读取聊天记录' },
{ id: 'preparingInput', label: '整理日报输入' },
{ id: 'requestingModel', label: '调用模型生成内容' },
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
]
const phaseIndex = (phase: ReportGenerationPhase): number => const phaseIndex = (phase: ReportGenerationPhase): number =>
REPORT_TASK_STEPS.findIndex((step) => step.id === phase) TASK_STEPS.findIndex((step) => step.id === phase)
export function ReportTaskStatusPanel({ export function ReportTaskStatusPanel({
phase, phase,
@@ -31,12 +38,12 @@ export function ReportTaskStatusPanel({
: phase === 'error' : phase === 'error'
? '生成失败' ? '生成失败'
: activeIndex >= 0 : activeIndex >= 0
? `${activeIndex + 1}/${REPORT_TASK_STEPS.length}` ? `${activeIndex + 1}/${TASK_STEPS.length}`
: '等待开始'} : '等待开始'}
</p> </p>
</div> </div>
<div className="report-task-steps"> <div className="report-task-steps">
{REPORT_TASK_STEPS.map((step, index) => { {TASK_STEPS.map((step, index) => {
const state = const state =
completedAll || (activeIndex >= 0 && index < activeIndex) completedAll || (activeIndex >= 0 && index < activeIndex)
? 'done' ? 'done'
@@ -11,7 +11,6 @@ interface ReportViewerProps {
onRegenerate: () => void onRegenerate: () => void
onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }> onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }> onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
onImageSizeChange: (size: { width: number; height: number } | null) => void
} }
export function ReportViewer({ export function ReportViewer({
@@ -20,8 +19,7 @@ export function ReportViewer({
onBackToConfigure, onBackToConfigure,
onRegenerate, onRegenerate,
onCopyImage, onCopyImage,
onReveal, onReveal
onImageSizeChange
}: ReportViewerProps): React.ReactElement { }: ReportViewerProps): React.ReactElement {
const [zoom, setZoom] = useState(1) const [zoom, setZoom] = useState(1)
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
@@ -35,10 +33,9 @@ export function ReportViewer({
setImageError('') setImageError('')
setZoom(1) setZoom(1)
setNaturalSize(null) setNaturalSize(null)
onImageSizeChange(null)
}) })
return () => window.cancelAnimationFrame(frame) return () => window.cancelAnimationFrame(frame)
}, [onImageSizeChange, report?.id]) }, [report?.id])
const title = useMemo( const title = useMemo(
() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), () => (report ? `${report.contactName} 群聊日报` : 'AI 日报'),
@@ -111,16 +108,13 @@ export function ReportViewer({
}} }}
onLoad={(event) => { onLoad={(event) => {
const image = event.currentTarget const image = event.currentTarget
const size = { setNaturalSize({
width: image.naturalWidth, width: image.naturalWidth,
height: image.naturalHeight height: image.naturalHeight
} })
setNaturalSize(size)
onImageSizeChange(size)
}} }}
onError={() => { onError={() => {
setImageError('日报图片加载失败') setImageError('日报图片加载失败')
onImageSizeChange(null)
}} }}
/> />
</div> </div>
@@ -38,6 +38,25 @@ export interface ReportGenerationResult {
paths: ReportPaths 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 { interface UseGroupReportGenerationArgs {
sourceContact: Contact | null sourceContact: Contact | null
summaryDateRange: SummaryDateRange summaryDateRange: SummaryDateRange
@@ -62,7 +81,7 @@ export interface RangeMessageState {
error: string error: string
} }
const withTimeout = async <T,>(promise: Promise<T>, label: string): Promise<T> => { const withTimeout = async <T>(promise: Promise<T>, label: string): Promise<T> => {
let timer: number | undefined let timer: number | undefined
const timeout = new Promise<never>((_, reject) => { const timeout = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), REPORT_STEP_TIMEOUT_MS) timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), REPORT_STEP_TIMEOUT_MS)
@@ -87,10 +106,25 @@ const selectedMessageTypeSet = (types: SummaryMessageType[]): Set<string> =>
) )
) )
const applyGroupMemberNames = async ( const estimateTokenCount = (length: number): number => Math.max(1, Math.ceil(length / 1.6))
contact: Contact,
messages: Message[] const estimateTokenUsage = (
): Promise<Message[]> => { inputMessages: { role: string; content: string }[],
output: string
): NonNullable<ReportGenerationMetadata['tokenUsage']> => {
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<Message[]> => {
let memberMap = new Map<string, { nickname: string; avatar: string }>() let memberMap = new Map<string, { nickname: string; avatar: string }>()
try { try {
const snapshot = await withTimeout(window.api.getGroupSnapshot(contact.md5), '读取群成员') const snapshot = await withTimeout(window.api.getGroupSnapshot(contact.md5), '读取群成员')
@@ -128,9 +162,11 @@ export function useGroupReportGeneration({
rangeState: RangeMessageState rangeState: RangeMessageState
generatedImage: string | null generatedImage: string | null
reportPaths: ReportPaths | null reportPaths: ReportPaths | null
generationMetadata: ReportGenerationMetadata
isGenerating: boolean isGenerating: boolean
generate: () => Promise<void> generate: () => Promise<void>
retry: () => Promise<void> retry: () => Promise<void>
resetGenerationStatus: () => void
clearError: () => void clearError: () => void
closeResult: () => void closeResult: () => void
copyImage: () => Promise<{ success: boolean; error?: string }> copyImage: () => Promise<{ success: boolean; error?: string }>
@@ -142,6 +178,9 @@ export function useGroupReportGeneration({
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' }) const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
const [generatedImage, setGeneratedImage] = useState<string | null>(null) const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null) const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
const [generationMetadata, setGenerationMetadata] = useState<ReportGenerationMetadata>({
generationLogs: []
})
const rangeRequestIdRef = useRef(0) const rangeRequestIdRef = useRef(0)
const isGenerating = const isGenerating =
@@ -218,6 +257,14 @@ export function useGroupReportGeneration({
[allowedTypes, rangeMessages] [allowedTypes, rangeMessages]
) )
const resetGenerationStatus = useCallback((): void => {
setPhase('idle')
setError('')
setGeneratedImage(null)
setReportPaths(null)
setGenerationMetadata({ generationLogs: [] })
}, [])
const generate = useCallback(async (): Promise<void> => { const generate = useCallback(async (): Promise<void> => {
if (isGenerating) return if (isGenerating) return
if (!sourceContact) { if (!sourceContact) {
@@ -241,36 +288,66 @@ export function useGroupReportGeneration({
return 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 <T>(label: string, task: () => Promise<T>): Promise<T> => {
const startedAt = new Date()
try {
return await task()
} finally {
pushLog(createStepLog(label, startedAt, new Date()))
}
}
setError('') setError('')
setGeneratedImage(null) setGeneratedImage(null)
setReportPaths(null) setReportPaths(null)
setGenerationMetadata({
modelName: modelConfig.model,
generationLogs: []
})
try { try {
const sourceMessages = const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
rangeState.status === 'success' ? rangeMessages : await loadRangeMessages(true)
if (rangeState.status === 'success') setPhase('loadingMessages')
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes) const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type)) const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息') if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
setPhase('preparingInput') setPhase('preparingInput')
const input = await trackStep('整理输入', async () => {
const namedReportMessages = await applyGroupMemberNames(sourceContact, filteredMessages) const namedReportMessages = await applyGroupMemberNames(sourceContact, filteredMessages)
const input = buildGroupReportInput(namedReportMessages, sourceContact, true) return buildGroupReportInput(namedReportMessages, sourceContact, true)
})
setPhase('requestingModel') setPhase('requestingModel')
const result = await withTimeout( const aiMessages = [
window.api.aiChat(
[
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT }, { role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt } { role: 'user', content: input.prompt }
], ]
modelConfig const result = await trackStep('AI 生成', () =>
), withTimeout(window.api.aiChat(aiMessages, modelConfig), 'AI 生成日报')
'AI 生成日报'
) )
if (!result.success || !result.data) throw new Error(result.error || '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) const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
setPhase('exportingReport') setPhase('exportingReport')
@@ -281,23 +358,39 @@ export function useGroupReportGeneration({
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) { if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
throw new Error(exported.error || '日报文件生成失败') 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) setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath }) 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') setPhase('success')
} catch (generateError) { } catch (generateError) {
setError(errorMessage(generateError)) setError(errorMessage(generateError))
setPhase('error') setPhase('error')
} }
}, [ }, [isGenerating, loadRangeMessages, modelConfig, sourceContact, summaryMessageTypes])
isGenerating,
loadRangeMessages,
modelConfig,
rangeMessages,
rangeState.status,
sourceContact,
summaryMessageTypes
])
const clearError = useCallback((): void => { const clearError = useCallback((): void => {
setError('') setError('')
@@ -327,9 +420,11 @@ export function useGroupReportGeneration({
rangeState, rangeState,
generatedImage, generatedImage,
reportPaths, reportPaths,
generationMetadata,
isGenerating, isGenerating,
generate, generate,
retry: generate, retry: generate,
resetGenerationStatus,
clearError, clearError,
closeResult, closeResult,
copyImage, copyImage,
+68 -2
View File
@@ -12,7 +12,14 @@ import {
} from '../../../shared/group-report' } from '../../../shared/group-report'
export type SummaryDateRange = 'today' | 'yesterday' | '7days' 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 }[] = [ export const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
{ value: 'today', label: '今天' }, { value: 'today', label: '今天' },
@@ -319,6 +326,56 @@ const normalizeHeat = (value: unknown): ReportHeat => {
return '中' 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 extractJson = (raw: string): unknown => {
const cleaned = raw const cleaned = raw
.trim() .trim()
@@ -327,7 +384,16 @@ const extractJson = (raw: string): unknown => {
const start = cleaned.indexOf('{') const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}') const end = cleaned.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON') 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 = ( export const parseGroupDailyReport = (
+12
View File
@@ -95,6 +95,18 @@ export interface GroupReportExportResult {
htmlPath?: string htmlPath?: string
pngPath?: string pngPath?: string
imageDataUrl?: string imageDataUrl?: string
exportTimings?: {
html?: {
startedAt: string
endedAt: string
duration: number
}
png?: {
startedAt: string
endedAt: string
duration: number
}
}
warnings?: string[] warnings?: string[]
error?: string error?: string
} }
+36
View File
@@ -15,6 +15,28 @@ export interface GeneratedReportRecord {
htmlStatus: ReportAssetStatus htmlStatus: ReportAssetStatus
pngStatus: ReportAssetStatus pngStatus: ReportAssetStatus
generatedImage?: string 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 { export interface SaveGeneratedReportRequest {
@@ -27,6 +49,20 @@ export interface SaveGeneratedReportRequest {
generatedImage?: string generatedImage?: string
htmlPath?: string htmlPath?: string
pngPath?: 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 { export interface ReportHistoryResult {