From de7f7bc8de1369b0c4cc080b32069656779e8bf4 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:52:51 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A1=E5=88=92=E4=BC=98=E5=8C=96=20P5/5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/cloudControlService.ts | 74 ++++- electron/services/config.ts | 4 +- electron/services/exportService.ts | 350 +++++++++++++++++++++-- electron/services/videoService.ts | 25 +- electron/services/wcdbCore.ts | 29 +- src/components/BatchTranscribeGlobal.tsx | 11 +- src/pages/ChatPage.scss | 30 ++ src/pages/ChatPage.tsx | 104 +++++-- src/pages/ExportPage.tsx | 83 +++++- src/stores/batchTranscribeStore.ts | 11 +- src/types/electron.d.ts | 6 + 11 files changed, 644 insertions(+), 83 deletions(-) diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts index c611bf0..c43dcd2 100644 --- a/electron/services/cloudControlService.ts +++ b/electron/services/cloudControlService.ts @@ -14,6 +14,7 @@ class CloudControlService { private deviceId: string = '' private timer: NodeJS.Timeout | null = null private pages: Set = new Set() + private platformVersionCache: string | null = null async init() { this.deviceId = this.getDeviceId() @@ -47,7 +48,12 @@ class CloudControlService { } private getPlatformVersion(): string { + if (this.platformVersionCache) { + return this.platformVersionCache + } + const os = require('os') + const fs = require('fs') const platform = process.platform if (platform === 'win32') { @@ -59,21 +65,79 @@ class CloudControlService { // Windows 11 是 10.0.22000+,且主版本必须是 10.0 if (major === 10 && minor === 0 && build >= 22000) { - return 'Windows 11' + this.platformVersionCache = 'Windows 11' + return this.platformVersionCache } else if (major === 10) { - return 'Windows 10' + this.platformVersionCache = 'Windows 10' + return this.platformVersionCache } - return `Windows ${release}` + this.platformVersionCache = `Windows ${release}` + return this.platformVersionCache } if (platform === 'darwin') { // `os.release()` returns Darwin kernel version (e.g. 25.3.0), // while cloud reporting expects the macOS product version (e.g. 26.3). const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release() - return `macOS ${macVersion}` + this.platformVersionCache = `macOS ${macVersion}` + return this.platformVersionCache } - return platform + if (platform === 'linux') { + try { + const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release'] + for (const filePath of osReleasePaths) { + if (!fs.existsSync(filePath)) { + continue + } + + const content = fs.readFileSync(filePath, 'utf8') + const values: Record = {} + + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const separatorIndex = trimmed.indexOf('=') + if (separatorIndex <= 0) { + continue + } + + const key = trimmed.slice(0, separatorIndex) + let value = trimmed.slice(separatorIndex + 1).trim() + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) { + value = value.slice(1, -1) + } + values[key] = value + } + + if (values.PRETTY_NAME) { + this.platformVersionCache = values.PRETTY_NAME + return this.platformVersionCache + } + + if (values.NAME && values.VERSION_ID) { + this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}` + return this.platformVersionCache + } + + if (values.NAME) { + this.platformVersionCache = values.NAME + return this.platformVersionCache + } + } + } catch (error) { + console.warn('[CloudControl] Failed to detect Linux distro version:', error) + } + + this.platformVersionCache = `Linux ${os.release()}` + return this.platformVersionCache + } + + this.platformVersionCache = platform + return this.platformVersionCache } recordPage(pageName: string) { diff --git a/electron/services/config.ts b/electron/services/config.ts index 5eb9c05..4b8324d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -123,7 +123,8 @@ export class ConfigService { const storeOptions: any = { name: 'WeFlow-config', - defaults + defaults, + projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow' } const runningInWorker = process.env.WEFLOW_WORKER === '1' if (runningInWorker) { @@ -131,7 +132,6 @@ export class ConfigService { if (cwd) { storeOptions.cwd = cwd } - storeOptions.projectName = String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow' } try { diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index b965cd8..cd13b16 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -133,6 +133,29 @@ export interface ExportProgress { exportedMessages?: number estimatedTotalMessages?: number writtenFiles?: number + mediaDoneFiles?: number + mediaCacheHitFiles?: number + mediaCacheMissFiles?: number + mediaCacheFillFiles?: number + mediaDedupReuseFiles?: number + mediaBytesWritten?: number +} + +interface MediaExportTelemetry { + doneFiles: number + cacheHitFiles: number + cacheMissFiles: number + cacheFillFiles: number + dedupReuseFiles: number + bytesWritten: number +} + +interface MediaSourceResolution { + sourcePath: string + cacheHit: boolean + cachePath?: string + fileStat?: { size: number; mtimeMs: number } + dedupeKey?: string } interface ExportTaskControl { @@ -218,6 +241,14 @@ class ExportService { private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED' private mediaFileCachePopulatePending = new Map>() private mediaFileCacheReadyDirs = new Set() + private mediaExportTelemetry: MediaExportTelemetry | null = null + private mediaRunSourceDedupMap = new Map() + private mediaFileCacheCleanupPending: Promise | null = null + private mediaFileCacheLastCleanupAt = 0 + private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 + private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024 + private readonly mediaFileCacheMaxFiles = 120000 + private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -456,6 +487,62 @@ class ExportService { return path.join(this.configService.getCacheBasePath(), 'export-media-files') } + private createEmptyMediaTelemetry(): MediaExportTelemetry { + return { + doneFiles: 0, + cacheHitFiles: 0, + cacheMissFiles: 0, + cacheFillFiles: 0, + dedupReuseFiles: 0, + bytesWritten: 0 + } + } + + private resetMediaRuntimeState(): void { + this.mediaExportTelemetry = this.createEmptyMediaTelemetry() + this.mediaRunSourceDedupMap.clear() + } + + private clearMediaRuntimeState(): void { + this.mediaExportTelemetry = null + this.mediaRunSourceDedupMap.clear() + } + + private getMediaTelemetrySnapshot(): Partial { + const stats = this.mediaExportTelemetry + if (!stats) return {} + return { + mediaDoneFiles: stats.doneFiles, + mediaCacheHitFiles: stats.cacheHitFiles, + mediaCacheMissFiles: stats.cacheMissFiles, + mediaCacheFillFiles: stats.cacheFillFiles, + mediaDedupReuseFiles: stats.dedupReuseFiles, + mediaBytesWritten: stats.bytesWritten + } + } + + private noteMediaTelemetry(delta: Partial): void { + if (!this.mediaExportTelemetry) return + if (Number.isFinite(delta.doneFiles)) { + this.mediaExportTelemetry.doneFiles += Math.max(0, Math.floor(Number(delta.doneFiles || 0))) + } + if (Number.isFinite(delta.cacheHitFiles)) { + this.mediaExportTelemetry.cacheHitFiles += Math.max(0, Math.floor(Number(delta.cacheHitFiles || 0))) + } + if (Number.isFinite(delta.cacheMissFiles)) { + this.mediaExportTelemetry.cacheMissFiles += Math.max(0, Math.floor(Number(delta.cacheMissFiles || 0))) + } + if (Number.isFinite(delta.cacheFillFiles)) { + this.mediaExportTelemetry.cacheFillFiles += Math.max(0, Math.floor(Number(delta.cacheFillFiles || 0))) + } + if (Number.isFinite(delta.dedupReuseFiles)) { + this.mediaExportTelemetry.dedupReuseFiles += Math.max(0, Math.floor(Number(delta.dedupReuseFiles || 0))) + } + if (Number.isFinite(delta.bytesWritten)) { + this.mediaExportTelemetry.bytesWritten += Math.max(0, Math.floor(Number(delta.bytesWritten || 0))) + } + } + private async ensureMediaFileCacheDir(dirPath: string): Promise { if (this.mediaFileCacheReadyDirs.has(dirPath)) return await fs.promises.mkdir(dirPath, { recursive: true }) @@ -529,6 +616,7 @@ class ExportService { await fs.promises.rm(tempPath, { force: true }).catch(() => { }) throw error }) + this.noteMediaTelemetry({ cacheFillFiles: 1 }) return cachePath } catch { return null @@ -544,15 +632,185 @@ class ExportService { private async resolvePreferredMediaSource( kind: 'image' | 'video' | 'emoji', sourcePath: string - ): Promise { + ): Promise { const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) - if (!resolved) return sourcePath + if (!resolved) { + return { + sourcePath, + cacheHit: false + } + } + const dedupeKey = `${kind}\u001f${resolved.cachePath}` if (await this.pathExists(resolved.cachePath)) { - return resolved.cachePath + return { + sourcePath: resolved.cachePath, + cacheHit: true, + cachePath: resolved.cachePath, + fileStat: resolved.fileStat, + dedupeKey + } } // 未命中缓存时异步回填,不阻塞当前导出路径 void this.populateMediaFileCache(kind, sourcePath) - return sourcePath + return { + sourcePath, + cacheHit: false, + cachePath: resolved.cachePath, + fileStat: resolved.fileStat, + dedupeKey + } + } + + private isHardlinkFallbackError(code: string | undefined): boolean { + return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP' + } + + private async hardlinkOrCopyFile(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string; linked?: boolean }> { + try { + await fs.promises.link(sourcePath, destPath) + return { success: true, linked: true } + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code === 'EEXIST') { + return { success: true, linked: true } + } + if (!this.isHardlinkFallbackError(code)) { + return { success: false, code } + } + } + + const copied = await this.copyFileOptimized(sourcePath, destPath) + if (!copied.success) return copied + return { success: true, linked: false } + } + + private async copyMediaWithCacheAndDedup( + kind: 'image' | 'video' | 'emoji', + sourcePath: string, + destPath: string + ): Promise<{ success: boolean; code?: string }> { + const resolved = await this.resolvePreferredMediaSource(kind, sourcePath) + if (resolved.cacheHit) { + this.noteMediaTelemetry({ cacheHitFiles: 1 }) + } else { + this.noteMediaTelemetry({ cacheMissFiles: 1 }) + } + + const dedupeKey = resolved.dedupeKey + if (dedupeKey) { + const reusedPath = this.mediaRunSourceDedupMap.get(dedupeKey) + if (reusedPath && reusedPath !== destPath && await this.pathExists(reusedPath)) { + const reused = await this.hardlinkOrCopyFile(reusedPath, destPath) + if (!reused.success) return reused + this.noteMediaTelemetry({ + doneFiles: 1, + dedupReuseFiles: 1, + bytesWritten: resolved.fileStat?.size || 0 + }) + return { success: true } + } + } + + const copied = resolved.cacheHit + ? await this.hardlinkOrCopyFile(resolved.sourcePath, destPath) + : await this.copyFileOptimized(resolved.sourcePath, destPath) + if (!copied.success) return copied + + if (dedupeKey) { + this.mediaRunSourceDedupMap.set(dedupeKey, destPath) + } + this.noteMediaTelemetry({ + doneFiles: 1, + bytesWritten: resolved.fileStat?.size || 0 + }) + return { success: true } + } + + private triggerMediaFileCacheCleanup(force = false): void { + const now = Date.now() + if (!force && now - this.mediaFileCacheLastCleanupAt < this.mediaFileCacheCleanupIntervalMs) return + if (this.mediaFileCacheCleanupPending) return + this.mediaFileCacheLastCleanupAt = now + + this.mediaFileCacheCleanupPending = this.cleanupMediaFileCache().finally(() => { + this.mediaFileCacheCleanupPending = null + }) + } + + private async cleanupMediaFileCache(): Promise { + const root = this.getMediaFileCacheRoot() + if (!await this.pathExists(root)) return + const now = Date.now() + const files: Array<{ filePath: string; size: number; mtimeMs: number }> = [] + const dirs: string[] = [] + + const stack = [root] + while (stack.length > 0) { + const current = stack.pop() as string + dirs.push(current) + let entries: fs.Dirent[] + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const entryPath = path.join(current, entry.name) + if (entry.isDirectory()) { + stack.push(entryPath) + continue + } + if (!entry.isFile()) continue + try { + const stat = await fs.promises.stat(entryPath) + if (!stat.isFile()) continue + files.push({ + filePath: entryPath, + size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, + mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 + }) + } catch { } + } + } + + if (files.length === 0) return + + let totalBytes = files.reduce((sum, item) => sum + item.size, 0) + let totalFiles = files.length + const ttlThreshold = now - this.mediaFileCacheTtlMs + const removalSet = new Set() + + for (const item of files) { + if (item.mtimeMs > 0 && item.mtimeMs < ttlThreshold) { + removalSet.add(item.filePath) + totalBytes -= item.size + totalFiles -= 1 + } + } + + if (totalBytes > this.mediaFileCacheMaxBytes || totalFiles > this.mediaFileCacheMaxFiles) { + const ordered = files + .filter((item) => !removalSet.has(item.filePath)) + .sort((a, b) => a.mtimeMs - b.mtimeMs) + for (const item of ordered) { + if (totalBytes <= this.mediaFileCacheMaxBytes && totalFiles <= this.mediaFileCacheMaxFiles) break + removalSet.add(item.filePath) + totalBytes -= item.size + totalFiles -= 1 + } + } + + if (removalSet.size === 0) return + + for (const filePath of removalSet) { + await fs.promises.rm(filePath, { force: true }).catch(() => { }) + } + + dirs.sort((a, b) => b.length - a.length) + for (const dirPath of dirs) { + if (dirPath === root) continue + await fs.promises.rmdir(dirPath).catch(() => { }) + } } private isMediaExportEnabled(options: ExportOptions): boolean { @@ -2398,6 +2656,7 @@ class ExportService { exportVideos?: boolean exportEmojis?: boolean exportVoiceAsText?: boolean + includeVideoPoster?: boolean includeVoiceWithTranscript?: boolean dirCache?: Set } @@ -2431,7 +2690,14 @@ class ExportService { } if (localType === 43 && options.exportVideos) { - return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) + return this.exportVideo( + msg, + sessionId, + mediaRootDir, + mediaRelativePrefix, + options.dirCache, + options.includeVideoPoster === true + ) } return null @@ -2510,7 +2776,13 @@ class ExportService { const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - await fs.promises.writeFile(destPath, Buffer.from(base64Data, 'base64')) + const buffer = Buffer.from(base64Data, 'base64') + await fs.promises.writeFile(destPath, buffer) + this.noteMediaTelemetry({ + doneFiles: 1, + cacheMissFiles: 1, + bytesWritten: buffer.length + }) return { relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), @@ -2524,8 +2796,7 @@ class ExportService { const ext = path.extname(sourcePath) || '.jpg' const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - const preferredSource = await this.resolvePreferredMediaSource('image', sourcePath) - const copied = await this.copyFileOptimized(preferredSource, destPath) + const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath) if (!copied.success) { if (copied.code === 'ENOENT') { console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) @@ -2692,6 +2963,10 @@ class ExportService { // voiceResult.data 是 base64 编码的 wav 数据 const wavBuffer = Buffer.from(voiceResult.data, 'base64') await fs.promises.writeFile(destPath, wavBuffer) + this.noteMediaTelemetry({ + doneFiles: 1, + bytesWritten: wavBuffer.length + }) return { relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), @@ -2746,8 +3021,7 @@ class ExportService { const key = msg.emojiMd5 || String(msg.localId) const fileName = `${key}${ext}` const destPath = path.join(emojisDir, fileName) - const preferredSource = await this.resolvePreferredMediaSource('emoji', localPath) - const copied = await this.copyFileOptimized(preferredSource, destPath) + const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath) if (!copied.success) return null return { @@ -2768,7 +3042,8 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - dirCache?: Set + dirCache?: Set, + includePoster = false ): Promise { try { const videoMd5 = msg.videoMd5 @@ -2780,7 +3055,7 @@ class ExportService { dirCache?.add(videosDir) } - const videoInfo = await videoService.getVideoInfo(videoMd5) + const videoInfo = await videoService.getVideoInfo(videoMd5, { includePoster }) if (!videoInfo.exists || !videoInfo.videoUrl) { return null } @@ -2789,14 +3064,13 @@ class ExportService { const fileName = path.basename(sourcePath) const destPath = path.join(videosDir, fileName) - const preferredSource = await this.resolvePreferredMediaSource('video', sourcePath) - const copied = await this.copyFileOptimized(preferredSource, destPath) + const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath) if (!copied.success) return null return { relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), kind: 'video', - posterDataUrl: videoInfo.coverUrl || videoInfo.thumbUrl + posterDataUrl: includePoster ? (videoInfo.coverUrl || videoInfo.thumbUrl) : undefined } } catch (e) { return null @@ -3854,6 +4128,7 @@ class ExportService { phaseProgress: 0, phaseTotal: mediaMessages.length, phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), estimatedTotalMessages: totalMessages }) @@ -3870,6 +4145,7 @@ class ExportService { exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -3883,7 +4159,8 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) @@ -4341,6 +4618,7 @@ class ExportService { phaseProgress: 0, phaseTotal: mediaMessages.length, phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), estimatedTotalMessages: totalMessages }) @@ -4356,6 +4634,7 @@ class ExportService { exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -4369,7 +4648,8 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) @@ -5152,6 +5432,7 @@ class ExportService { phaseProgress: 0, phaseTotal: mediaMessages.length, phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), estimatedTotalMessages: totalMessages }) @@ -5167,6 +5448,7 @@ class ExportService { exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -5180,7 +5462,8 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) @@ -5822,6 +6105,7 @@ class ExportService { phaseProgress: 0, phaseTotal: mediaMessages.length, phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), estimatedTotalMessages: totalMessages }) @@ -5837,6 +6121,7 @@ class ExportService { exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -5850,7 +6135,8 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) @@ -6164,6 +6450,7 @@ class ExportService { phaseProgress: 0, phaseTotal: mediaMessages.length, phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), estimatedTotalMessages: totalMessages }) @@ -6178,7 +6465,9 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -6191,7 +6480,8 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) @@ -6574,6 +6864,7 @@ class ExportService { phaseProgress: 0, phaseTotal: mediaMessages.length, phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), estimatedTotalMessages: totalMessages }) @@ -6588,6 +6879,7 @@ class ExportService { exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', includeVoiceWithTranscript: true, exportVideos: options.exportVideos, dirCache: mediaDirCache @@ -6603,7 +6895,8 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) @@ -7276,8 +7569,12 @@ class ExportService { const successSessionIds: string[] = [] const failedSessionIds: string[] = [] const progressEmitter = this.createProgressEmitter(onProgress) + let attachMediaTelemetry = false const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => { - progressEmitter.emit(progress, options) + const payload = attachMediaTelemetry + ? { ...progress, ...this.getMediaTelemetrySnapshot() } + : progress + progressEmitter.emit(payload, options) } try { @@ -7286,12 +7583,17 @@ class ExportService { return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error } } + this.resetMediaRuntimeState() const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options) ? { ...options, exportVoiceAsText: false } : options const exportMediaEnabled = effectiveOptions.exportMedia === true && Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis) + attachMediaTelemetry = exportMediaEnabled + if (exportMediaEnabled) { + this.triggerMediaFileCacheCleanup() + } const rawWriteLayout = this.configService.get('exportWriteLayout') const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' ? rawWriteLayout @@ -7745,6 +8047,8 @@ class ExportService { } catch (e) { progressEmitter.flush() return { success: false, successCount, failCount, error: String(e) } + } finally { + this.clearMediaRuntimeState() } } } diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 4e36d00..761e017 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -355,7 +355,7 @@ class VideoService { return index } - private getVideoInfoFromIndex(index: Map, md5: string): VideoInfo | null { + private getVideoInfoFromIndex(index: Map, md5: string, includePoster = true): VideoInfo | null { const normalizedMd5 = String(md5 || '').trim().toLowerCase() if (!normalizedMd5) return null @@ -371,6 +371,12 @@ class VideoService { const entry = index.get(key) if (!entry?.videoPath) continue if (!existsSync(entry.videoPath)) continue + if (!includePoster) { + return { + videoUrl: entry.videoPath, + exists: true + } + } return { videoUrl: entry.videoPath, coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'), @@ -382,7 +388,7 @@ class VideoService { return null } - private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string): VideoInfo | null { + private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null { try { const yearMonthDirs = readdirSync(videoBaseDir) .filter((dir) => { @@ -399,6 +405,12 @@ class VideoService { const dirPath = join(videoBaseDir, yearMonth) const videoPath = join(dirPath, `${realVideoMd5}.mp4`) if (!existsSync(videoPath)) continue + if (!includePoster) { + return { + videoUrl: videoPath, + exists: true + } + } const baseMd5 = realVideoMd5.replace(/_raw$/, '') const coverPath = join(dirPath, `${baseMd5}.jpg`) const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) @@ -420,8 +432,9 @@ class VideoService { * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg */ - async getVideoInfo(videoMd5: string): Promise { + async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise { const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase() + const includePoster = options?.includePoster !== false const dbPath = this.getDbPath() const wxid = this.getMyWxid() @@ -433,7 +446,7 @@ class VideoService { } const scopeKey = this.getScopeKey(dbPath, wxid) - const cacheKey = `${scopeKey}|${normalizedMd5}` + const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}` const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey) if (cachedInfo) return cachedInfo @@ -452,13 +465,13 @@ class VideoService { } const index = this.getOrBuildVideoIndex(videoBaseDir) - const indexed = this.getVideoInfoFromIndex(index, realVideoMd5) + const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster) if (indexed) { this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries) return indexed } - const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5) + const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster) if (fallback) { this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries) return fallback diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index af24a2f..028f2b5 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1249,25 +1249,18 @@ export class WcdbCore { } } - private preserveInt64FieldsInJson(jsonStr: string, fieldNames: string[]): string { - let normalized = String(jsonStr || '') - for (const fieldName of fieldNames) { - const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = new RegExp(`("${escaped}"\\s*:\\s*)(-?\\d{16,})`, 'g') - normalized = normalized.replace(pattern, '$1"$2"') - } - return normalized - } - private parseMessageJson(jsonStr: string): any { - const normalized = this.preserveInt64FieldsInJson(jsonStr, [ - 'server_id', - 'serverId', - 'ServerId', - 'msg_server_id', - 'msgServerId', - 'MsgServerId' - ]) + const raw = String(jsonStr || '') + if (!raw) return [] + // 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。 + const needsInt64Normalize = /"(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw) + if (!needsInt64Normalize) { + return JSON.parse(raw) + } + const normalized = raw.replace( + /("(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g, + '$1"$2"' + ) return JSON.parse(normalized) } diff --git a/src/components/BatchTranscribeGlobal.tsx b/src/components/BatchTranscribeGlobal.tsx index 3aa7c10..0c6d825 100644 --- a/src/components/BatchTranscribeGlobal.tsx +++ b/src/components/BatchTranscribeGlobal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { createPortal } from 'react-dom' -import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react' +import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock, Mic } from 'lucide-react' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import '../styles/batchTranscribe.scss' @@ -17,6 +17,7 @@ export const BatchTranscribeGlobal: React.FC = () => { result, sessionName, startTime, + taskType, setShowToast, setShowResult } = useBatchTranscribeStore() @@ -64,7 +65,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
- 批量转写中{sessionName ? `(${sessionName})` : ''} + {taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}
+ +
{batchVoiceDates.length > 0 && (
@@ -6621,12 +6683,16 @@ function ChatPage(props: ChatPageProps) {
预计耗时: - 约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟 + 约 {batchVoiceTaskMinutes} 分钟
- 批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。 + + {batchVoiceTaskType === 'decrypt' + ? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。' + : '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'} +
@@ -6635,7 +6701,7 @@ function ChatPage(props: ChatPageProps) {
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b904a0e..2bb57bf 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -109,6 +109,12 @@ interface TaskProgress { estimatedTotalMessages: number collectedMessages: number writtenFiles: number + mediaDoneFiles: number + mediaCacheHitFiles: number + mediaCacheMissFiles: number + mediaCacheFillFiles: number + mediaDedupReuseFiles: number + mediaBytesWritten: number } type TaskPerfStage = 'collect' | 'build' | 'write' | 'other' @@ -263,7 +269,13 @@ const createEmptyProgress = (): TaskProgress => ({ exportedMessages: 0, estimatedTotalMessages: 0, collectedMessages: 0, - writtenFiles: 0 + writtenFiles: 0, + mediaDoneFiles: 0, + mediaCacheHitFiles: 0, + mediaCacheMissFiles: 0, + mediaCacheFillFiles: 0, + mediaDedupReuseFiles: 0, + mediaBytesWritten: 0 }) const createEmptyTaskPerformance = (): TaskPerformance => ({ @@ -1302,6 +1314,17 @@ const TaskCenterModal = memo(function TaskCenterModal({ : `已收集 ${collectedMessages.toLocaleString()} 条` const phaseProgress = Math.max(0, Math.floor(task.progress.phaseProgress || 0)) const phaseTotal = Math.max(0, Math.floor(task.progress.phaseTotal || 0)) + const mediaDoneFiles = Math.max(0, Math.floor(task.progress.mediaDoneFiles || 0)) + const mediaCacheHitFiles = Math.max(0, Math.floor(task.progress.mediaCacheHitFiles || 0)) + const mediaCacheMissFiles = Math.max(0, Math.floor(task.progress.mediaCacheMissFiles || 0)) + const mediaDedupReuseFiles = Math.max(0, Math.floor(task.progress.mediaDedupReuseFiles || 0)) + const mediaCacheTotal = mediaCacheHitFiles + mediaCacheMissFiles + const mediaCacheMetricLabel = mediaCacheTotal > 0 + ? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}` + : '' + const mediaDedupMetricLabel = mediaDedupReuseFiles > 0 + ? `复用 ${mediaDedupReuseFiles}` + : '' const phaseMetricLabel = phaseTotal > 0 ? ( task.progress.phase === 'exporting-media' @@ -1311,6 +1334,9 @@ const TaskCenterModal = memo(function TaskCenterModal({ : '' ) : '' + const mediaLiveMetricLabel = task.progress.phase === 'exporting-media' + ? (mediaDoneFiles > 0 ? `已处理 ${mediaDoneFiles}` : '') + : '' const sessionProgressLabel = completedSessionTotal > 0 ? `会话 ${completedSessionCount}/${completedSessionTotal}` : '会话处理中' @@ -1336,6 +1362,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
{`${sessionProgressLabel} · ${effectiveMessageProgressLabel}`} {phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''} + {mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''} + {mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''} + {mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''} {task.status === 'running' && currentSessionRatio !== null ? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)` : ''} @@ -4280,6 +4309,42 @@ function ExportPage() { const writtenFiles = Number.isFinite(payload.writtenFiles) ? Math.max(task.progress.writtenFiles, Math.max(0, Math.floor(Number(payload.writtenFiles || 0)))) : task.progress.writtenFiles + const prevMediaDoneFiles = Number.isFinite(task.progress.mediaDoneFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaDoneFiles || 0))) + : 0 + const prevMediaCacheHitFiles = Number.isFinite(task.progress.mediaCacheHitFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaCacheHitFiles || 0))) + : 0 + const prevMediaCacheMissFiles = Number.isFinite(task.progress.mediaCacheMissFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaCacheMissFiles || 0))) + : 0 + const prevMediaCacheFillFiles = Number.isFinite(task.progress.mediaCacheFillFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaCacheFillFiles || 0))) + : 0 + const prevMediaDedupReuseFiles = Number.isFinite(task.progress.mediaDedupReuseFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaDedupReuseFiles || 0))) + : 0 + const prevMediaBytesWritten = Number.isFinite(task.progress.mediaBytesWritten) + ? Math.max(0, Math.floor(Number(task.progress.mediaBytesWritten || 0))) + : 0 + const mediaDoneFiles = Number.isFinite(payload.mediaDoneFiles) + ? Math.max(prevMediaDoneFiles, Math.max(0, Math.floor(Number(payload.mediaDoneFiles || 0)))) + : prevMediaDoneFiles + const mediaCacheHitFiles = Number.isFinite(payload.mediaCacheHitFiles) + ? Math.max(prevMediaCacheHitFiles, Math.max(0, Math.floor(Number(payload.mediaCacheHitFiles || 0)))) + : prevMediaCacheHitFiles + const mediaCacheMissFiles = Number.isFinite(payload.mediaCacheMissFiles) + ? Math.max(prevMediaCacheMissFiles, Math.max(0, Math.floor(Number(payload.mediaCacheMissFiles || 0)))) + : prevMediaCacheMissFiles + const mediaCacheFillFiles = Number.isFinite(payload.mediaCacheFillFiles) + ? Math.max(prevMediaCacheFillFiles, Math.max(0, Math.floor(Number(payload.mediaCacheFillFiles || 0)))) + : prevMediaCacheFillFiles + const mediaDedupReuseFiles = Number.isFinite(payload.mediaDedupReuseFiles) + ? Math.max(prevMediaDedupReuseFiles, Math.max(0, Math.floor(Number(payload.mediaDedupReuseFiles || 0)))) + : prevMediaDedupReuseFiles + const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten) + ? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0)))) + : prevMediaBytesWritten return { ...task, progress: { @@ -4295,7 +4360,13 @@ function ExportPage() { ? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated) : (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0), collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages), - writtenFiles + writtenFiles, + mediaDoneFiles, + mediaCacheHitFiles, + mediaCacheMissFiles, + mediaCacheFillFiles, + mediaDedupReuseFiles, + mediaBytesWritten }, settledSessionIds: nextSettledSessionIds, performance @@ -4336,7 +4407,13 @@ function ExportPage() { exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages, estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages, collectedMessages: task.progress.collectedMessages, - writtenFiles: task.progress.writtenFiles + writtenFiles: task.progress.writtenFiles, + mediaDoneFiles: task.progress.mediaDoneFiles, + mediaCacheHitFiles: task.progress.mediaCacheHitFiles, + mediaCacheMissFiles: task.progress.mediaCacheMissFiles, + mediaCacheFillFiles: task.progress.mediaCacheFillFiles, + mediaDedupReuseFiles: task.progress.mediaDedupReuseFiles, + mediaBytesWritten: task.progress.mediaBytesWritten } } }) diff --git a/src/stores/batchTranscribeStore.ts b/src/stores/batchTranscribeStore.ts index a6e1f1f..55cf199 100644 --- a/src/stores/batchTranscribeStore.ts +++ b/src/stores/batchTranscribeStore.ts @@ -1,8 +1,12 @@ import { create } from 'zustand' +export type BatchVoiceTaskType = 'transcribe' | 'decrypt' + export interface BatchTranscribeState { /** 是否正在批量转写 */ isBatchTranscribing: boolean + /** 当前批量任务类型 */ + taskType: BatchVoiceTaskType /** 转写进度 */ progress: { current: number; total: number } /** 是否显示进度浮窗 */ @@ -16,7 +20,7 @@ export interface BatchTranscribeState { sessionName: string // Actions - startTranscribe: (total: number, sessionName: string) => void + startTranscribe: (total: number, sessionName: string, taskType?: BatchVoiceTaskType) => void updateProgress: (current: number, total: number) => void finishTranscribe: (success: number, fail: number) => void setShowToast: (show: boolean) => void @@ -26,6 +30,7 @@ export interface BatchTranscribeState { export const useBatchTranscribeStore = create((set) => ({ isBatchTranscribing: false, + taskType: 'transcribe', progress: { current: 0, total: 0 }, showToast: false, showResult: false, @@ -33,8 +38,9 @@ export const useBatchTranscribeStore = create((set) => ({ sessionName: '', startTime: 0, - startTranscribe: (total, sessionName) => set({ + startTranscribe: (total, sessionName, taskType = 'transcribe') => set({ isBatchTranscribing: true, + taskType, showToast: true, progress: { current: 0, total }, showResult: false, @@ -60,6 +66,7 @@ export const useBatchTranscribeStore = create((set) => ({ reset: () => set({ isBatchTranscribing: false, + taskType: 'transcribe', progress: { current: 0, total: 0 }, showToast: false, showResult: false, diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 9f4caeb..b8e9f52 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -865,6 +865,12 @@ export interface ExportProgress { exportedMessages?: number estimatedTotalMessages?: number writtenFiles?: number + mediaDoneFiles?: number + mediaCacheHitFiles?: number + mediaCacheMissFiles?: number + mediaCacheFillFiles?: number + mediaDedupReuseFiles?: number + mediaBytesWritten?: number } export interface WxidInfo {