mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
fix: 完善 AI 日报详情状态与解析容错
This commit is contained in:
@@ -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
@@ -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() }
|
||||
|
||||
@@ -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: '未找到要删除的日报记录' }
|
||||
|
||||
Vendored
+29
-11
@@ -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
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user