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