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)
if (!snapshot) {
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(
`enrich skipped: group snapshot not available for "${metadata.talker}"`
)
metadata.warnings.push(`enrich skipped: group snapshot not available for "${metadata.talker}"`)
return
}
@@ -331,14 +329,30 @@ export const exportGroupReport = async (
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const htmlStartedAt = new Date()
const html = await renderReportHtml(request)
await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
const pngEndedAt = new Date()
return {
success: true,
htmlPath,
pngPath,
imageDataUrl,
exportTimings: {
html: {
startedAt: htmlStartedAt.toISOString(),
endedAt: htmlEndedAt.toISOString(),
duration: htmlEndedAt.getTime() - htmlStartedAt.getTime()
},
png: {
startedAt: pngStartedAt.toISOString(),
endedAt: pngEndedAt.toISOString(),
duration: pngEndedAt.getTime() - pngStartedAt.getTime()
}
},
warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined
}
} catch (error) {
+60 -52
View File
@@ -21,15 +21,8 @@ import { DatabaseKeyStore } from './database-key-store'
import { KeyServiceMac } from './key-service-mac'
import { KeyService as KeyServiceWin } from './key-service-win'
import * as chat from './services/chat-service'
import {
apiServer
} from './http-server'
import {
loadSettings,
saveSettings,
getSettingsPath,
AppSettings
} from './services/settings-store'
import { apiServer } from './http-server'
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
import {
getBootstrapCache,
getCachedMessages,
@@ -57,7 +50,8 @@ let tray: Tray | null = null
// WCDB's Windows runtime checks the host application name during wcdb_init.
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
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 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<AppSettings>) => {
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() }
+45 -8
View File
@@ -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<string | undefined>
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 (
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<SaveGeneratedReportResult> {
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<DeleteGeneratedReportResult> {
export async function deleteGeneratedReport(
reportId: string
): Promise<DeleteGeneratedReportResult> {
try {
const jsonFiles = await walkJsonFiles(getReportsRoot())
for (const jsonPath of jsonFiles) {
@@ -191,7 +225,10 @@ export async function deleteGeneratedReport(reportId: string): Promise<DeleteGen
)
return { success: true, deletedId: reportId }
} 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: '未找到要删除的日报记录' }
+29 -11
View File
@@ -70,7 +70,17 @@ declare global {
aiChat: (
messages: { role: string; content: 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 }>
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
+98 -66
View File
@@ -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"
/>
<circle
cx="12"
cy="12"
r="3"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
/>
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
{hidden && (
<path
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 VIEW_MESSAGE_LIMIT = 600
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 => {
@@ -175,10 +167,8 @@ function App(): React.ReactElement {
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
const [selectedReportImageSize, setSelectedReportImageSize] = useState<{
width: number
height: number
} | null>(null)
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
const [isSavingGeneratedReport, setIsSavingGeneratedReport] = useState(false)
const [reportNotice, setReportNotice] = useState('')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
@@ -225,8 +215,7 @@ function App(): React.ReactElement {
}
}, [])
const waitForPaint = (): Promise<void> =>
new Promise((resolve) => window.setTimeout(resolve, 80))
const waitForPaint = (): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, 80))
const refreshSelfInfo = async (): Promise<void> => {
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<void> => {
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<AppPage, 'archive' | 'report'>): React.ReactElement => {
const renderPlaceholderPage = (
page: Exclude<AppPage, 'archive' | 'report'>
): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
search: '检索',
export: '导出',
@@ -974,7 +1008,7 @@ function App(): React.ReactElement {
</div>
)
const renderReportWorkspace = (): React.ReactElement => (
const renderReportWorkspace = (): React.ReactElement =>
reportWorkspaceView === 'result' ? (
<div className="report-center-page">
<ReportHistorySidebar
@@ -982,10 +1016,7 @@ function App(): React.ReactElement {
selectedReportId={selectedReportId}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onSelectReport={(reportId) => {
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}
/>
<ReportSettingsPanel
report={selectedReport}
imageSize={selectedReportImageSize}
onReveal={handleRevealReport}
/>
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
</div>
) : (
<div className="report-page">
@@ -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 {
<ReportTaskStatusPanel
phase={reportGeneration.phase}
error={reportGeneration.error}
onRetry={() => void reportGeneration.retry()}
onRetry={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.retry()
}}
/>
</div>
)
)
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'
+52 -3
View File
@@ -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) {
@@ -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({
<span>PNG </span>
<b>{report.pngStatus === 'ready' ? '已保存' : '缺失'}</b>
</div>
{imageSize && (
{report.imageSize && (
<div>
<span></span>
<b>
{imageSize.width} x {imageSize.height}
{report.imageSize.width} x {report.imageSize.height}
</b>
</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 {
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({
<p></p>
)}
</section>
<ReportExportStatus report={report} imageSize={imageSize} onReveal={onReveal} />
<ReportExportStatus report={report} onReveal={onReveal} />
<section className="report-settings-section">
<h3></h3>
{path ? (
@@ -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<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 =>
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}`
: '等待开始'}
</p>
</div>
<div className="report-task-steps">
{REPORT_TASK_STEPS.map((step, index) => {
{TASK_STEPS.map((step, index) => {
const state =
completedAll || (activeIndex >= 0 && index < activeIndex)
? 'done'
@@ -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)
}}
/>
</div>
@@ -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 <T,>(promise: Promise<T>, label: string): Promise<T> => {
const withTimeout = async <T>(promise: Promise<T>, label: string): Promise<T> => {
let timer: number | undefined
const timeout = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), REPORT_STEP_TIMEOUT_MS)
@@ -87,10 +106,25 @@ const selectedMessageTypeSet = (types: SummaryMessageType[]): Set<string> =>
)
)
const applyGroupMemberNames = async (
contact: Contact,
messages: Message[]
): Promise<Message[]> => {
const estimateTokenCount = (length: number): number => Math.max(1, Math.ceil(length / 1.6))
const estimateTokenUsage = (
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 }>()
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<void>
retry: () => Promise<void>
resetGenerationStatus: () => void
clearError: () => void
closeResult: () => void
copyImage: () => Promise<{ success: boolean; error?: string }>
@@ -142,6 +178,9 @@ export function useGroupReportGeneration({
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
const [generationMetadata, setGenerationMetadata] = useState<ReportGenerationMetadata>({
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<void> => {
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 <T>(label: string, task: () => Promise<T>): Promise<T> => {
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,
+68 -2
View File
@@ -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 = (
+12
View File
@@ -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
}
+36
View File
@@ -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 {