From 63ac715792be7f4fa081d4458df969d49c8a0bfc Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:09:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86html=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 4 + electron/preload.ts | 2 + electron/services/chatService.ts | 75 +++- electron/services/exportService.ts | 648 ++++++++++++++++++----------- src/types/electron.d.ts | 14 +- 5 files changed, 488 insertions(+), 255 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 308444c..ebfd428 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -959,6 +959,10 @@ function registerIpcHandlers() { }) // 导出相关 + ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => { + return exportService.getExportStats(sessionIds, options) + }) + ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { const onProgress = (progress: ExportProgress) => { if (!event.sender.isDestroyed()) { diff --git a/electron/preload.ts b/electron/preload.ts index 849e11d..b6a8559 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -239,6 +239,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 导出 export: { + getExportStats: (sessionIds: string[], options: any) => + ipcRenderer.invoke('export:getExportStats', sessionIds, options), exportSessions: (sessionIds: string[], outputDir: string, options: any) => ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), exportSession: (sessionId: string, outputPath: string, options: any) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index c0568e2..2a20a06 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -117,10 +117,13 @@ class ChatService { private voiceWavCache = new Map() private voiceTranscriptCache = new Map() private voiceTranscriptPending = new Map>() + private transcriptCacheLoaded = false + private transcriptCacheDirty = false + private transcriptFlushTimer: ReturnType | null = null private mediaDbsCache: string[] | null = null private mediaDbsCacheTime = 0 private readonly mediaDbsCacheTtl = 300000 // 5分钟 - private readonly voiceCacheMaxEntries = 50 + private readonly voiceWavCacheMaxEntries = 50 // 缓存 media.db 的表结构信息 private mediaDbSchemaCache = new Map { const startTime = Date.now() + // 确保磁盘缓存已加载 + this.loadTranscriptCacheIfNeeded() try { let msgCreateTime = createTime @@ -3625,18 +3630,76 @@ class ChatService { private cacheVoiceWav(cacheKey: string, wavData: Buffer): void { this.voiceWavCache.set(cacheKey, wavData) - if (this.voiceWavCache.size > this.voiceCacheMaxEntries) { + if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) { const oldestKey = this.voiceWavCache.keys().next().value if (oldestKey) this.voiceWavCache.delete(oldestKey) } } + /** 获取持久化转写缓存文件路径 */ + private getTranscriptCachePath(): string { + const cachePath = this.configService.get('cachePath') + const base = cachePath || join(app.getPath('documents'), 'WeFlow') + return join(base, 'Voices', 'transcripts.json') + } + + /** 首次访问时从磁盘加载转写缓存 */ + private loadTranscriptCacheIfNeeded(): void { + if (this.transcriptCacheLoaded) return + this.transcriptCacheLoaded = true + try { + const filePath = this.getTranscriptCachePath() + if (existsSync(filePath)) { + const raw = readFileSync(filePath, 'utf-8') + const data = JSON.parse(raw) as Record + for (const [k, v] of Object.entries(data)) { + if (typeof v === 'string') this.voiceTranscriptCache.set(k, v) + } + console.log(`[Transcribe] 从磁盘加载了 ${this.voiceTranscriptCache.size} 条转写缓存`) + } + } catch (e) { + console.error('[Transcribe] 加载转写缓存失败:', e) + } + } + + /** 将转写缓存持久化到磁盘(防抖 3 秒) */ + private scheduleTranscriptFlush(): void { + if (this.transcriptFlushTimer) return + this.transcriptFlushTimer = setTimeout(() => { + this.transcriptFlushTimer = null + this.flushTranscriptCache() + }, 3000) + } + + /** 立即写入转写缓存到磁盘 */ + flushTranscriptCache(): void { + if (!this.transcriptCacheDirty) return + try { + const filePath = this.getTranscriptCachePath() + const dir = dirname(filePath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const obj: Record = {} + for (const [k, v] of this.voiceTranscriptCache) obj[k] = v + writeFileSync(filePath, JSON.stringify(obj), 'utf-8') + this.transcriptCacheDirty = false + } catch (e) { + console.error('[Transcribe] 写入转写缓存失败:', e) + } + } + private cacheVoiceTranscript(cacheKey: string, transcript: string): void { this.voiceTranscriptCache.set(cacheKey, transcript) - if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) { - const oldestKey = this.voiceTranscriptCache.keys().next().value - if (oldestKey) this.voiceTranscriptCache.delete(oldestKey) - } + this.transcriptCacheDirty = true + this.scheduleTranscriptFlush() + } + + /** + * 检查某个语音消息是否已有缓存的转写结果 + */ + hasTranscriptCache(sessionId: string, msgId: string, createTime?: number): boolean { + this.loadTranscriptCacheIfNeeded() + const cacheKey = this.getVoiceCacheKey(sessionId, msgId, createTime) + return this.voiceTranscriptCache.has(cacheKey) } /** diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 82864e1..3a5c939 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -106,6 +106,9 @@ export interface ExportProgress { total: number currentSession: string phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' + phaseProgress?: number + phaseTotal?: number + phaseLabel?: string } // 并发控制:限制同时执行的 Promise 数量 @@ -847,16 +850,30 @@ class ExportService { } private escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') + return value.replace(/[&<>"']/g, c => { + switch (c) { + case '&': return '&' + case '<': return '<' + case '>': return '>' + case '"': return '"' + case "'": return ''' + default: return c + } + }) } private escapeAttribute(value: string): string { - return this.escapeHtml(value).replace(/`/g, '`') + return value.replace(/[&<>"'`]/g, c => { + switch (c) { + case '&': return '&' + case '<': return '<' + case '>': return '>' + case '"': return '"' + case "'": return ''' + case '`': return '`' + default: return c + } + }) } private getAvatarFallback(name: string): string { @@ -997,7 +1014,9 @@ class ExportService { if (index % 2 === 1) { const emojiDataUrl = this.getInlineEmojiDataUrl(part) if (emojiDataUrl) { - return `[${this.escapeAttribute(part)}]` + // Cache full tag to avoid re-escaping data URL every time + const escapedName = this.escapeAttribute(part) + return `[${escapedName}]` } return this.escapeHtml(`[${part}]`) } @@ -1135,22 +1154,19 @@ class ExportService { } // 复制文件 - if (fs.existsSync(sourcePath)) { - const ext = path.extname(sourcePath) || '.jpg' - const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` - const destPath = path.join(imagesDir, fileName) + if (!fs.existsSync(sourcePath)) return null + const ext = path.extname(sourcePath) || '.jpg' + const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` + const destPath = path.join(imagesDir, fileName) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(sourcePath, destPath) - } - - return { - relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), - kind: 'image' - } + if (!fs.existsSync(destPath)) { + fs.copyFileSync(sourcePath, destPath) } - return null + return { + relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), + kind: 'image' + } } catch (e) { return null } @@ -1771,9 +1787,10 @@ class ExportService { fs.mkdirSync(avatarsDir, { recursive: true }) } - for (const member of members) { + const AVATAR_CONCURRENCY = 8 + await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => { const fileInfo = this.resolveAvatarFile(member.avatarUrl) - if (!fileInfo) continue + if (!fileInfo) return try { let data: Buffer | null = null let mime = fileInfo.mime @@ -1788,7 +1805,7 @@ class ExportService { mime = downloaded.mime || mime } } - if (!data) continue + if (!data) return // 优先使用内容检测出的 MIME 类型 const detectedMime = this.detectMimeType(data) @@ -1805,15 +1822,19 @@ class ExportService { const filename = `${sanitizedUsername}${ext}` const avatarPath = path.join(avatarsDir, filename) - // 保存头像文件 - await fs.promises.writeFile(avatarPath, data) + // 跳过已存在文件 + try { + await fs.promises.access(avatarPath) + } catch { + await fs.promises.writeFile(avatarPath, data) + } // 返回相对路径 result.set(member.username, `avatars/${filename}`) } catch { - continue + return } - } + }) return result } @@ -2001,11 +2022,15 @@ class ExportService { current: 20, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) // 并行导出媒体,并发数跟随导出设置 const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -2018,6 +2043,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -2029,14 +2066,28 @@ class ExportService { current: 40, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) // 并行转写语音,限制 4 个并发(转写比较耗资源) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 40, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -2335,10 +2386,14 @@ class ExportService { current: 15, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -2351,6 +2406,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 15, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -2362,13 +2429,27 @@ class ExportService { current: 35, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 35, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -2744,10 +2825,14 @@ class ExportService { current: 35, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -2760,6 +2845,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 35, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -2771,13 +2868,27 @@ class ExportService { current: 50, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 50, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -3074,10 +3185,14 @@ class ExportService { current: 25, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -3090,6 +3205,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 25, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -3100,13 +3227,27 @@ class ExportService { current: 45, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 45, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -3231,158 +3372,83 @@ class ExportService { private getVirtualScrollScript(): string { return ` - class VirtualScroller { - constructor(container, list, data, renderItem) { + class ChunkedRenderer { + constructor(container, data, renderItem) { this.container = container; - this.list = list; this.data = data; this.renderItem = renderItem; - - this.rowHeight = 80; // Estimated height - this.buffer = 5; - this.heightCache = new Map(); - this.visibleItems = new Set(); - - this.spacer = document.createElement('div'); - this.spacer.className = 'virtual-scroll-spacer'; - this.content = document.createElement('div'); - this.content.className = 'virtual-scroll-content'; - - this.container.appendChild(this.spacer); - this.container.appendChild(this.content); - - this.container.addEventListener('scroll', () => this.onScroll()); - window.addEventListener('resize', () => this.onScroll()); - - this.updateTotalHeight(); - this.onScroll(); + this.batchSize = 100; + this.rendered = 0; + this.loading = false; + + this.list = document.createElement('div'); + this.list.className = 'message-list'; + this.container.appendChild(this.list); + + this.sentinel = document.createElement('div'); + this.sentinel.className = 'load-sentinel'; + this.container.appendChild(this.sentinel); + + this.renderBatch(); + + this.observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !this.loading) { + this.renderBatch(); + } + }, { root: this.container, rootMargin: '600px' }); + this.observer.observe(this.sentinel); + } + + renderBatch() { + if (this.rendered >= this.data.length) return; + this.loading = true; + const end = Math.min(this.rendered + this.batchSize, this.data.length); + const fragment = document.createDocumentFragment(); + for (let i = this.rendered; i < end; i++) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = this.renderItem(this.data[i], i); + if (wrapper.firstElementChild) fragment.appendChild(wrapper.firstElementChild); + } + this.list.appendChild(fragment); + this.rendered = end; + this.loading = false; } setData(newData) { this.data = newData; - this.heightCache.clear(); - this.content.innerHTML = ''; + this.rendered = 0; + this.list.innerHTML = ''; this.container.scrollTop = 0; - this.updateTotalHeight(); - this.onScroll(); - - // Show/Hide empty state if (this.data.length === 0) { - this.content.innerHTML = '
暂无消息
'; + this.list.innerHTML = '
暂无消息
'; + return; } + this.renderBatch(); } - updateTotalHeight() { - let total = 0; - for (let i = 0; i < this.data.length; i++) { - total += this.heightCache.get(i) || this.rowHeight; - } - this.spacer.style.height = total + 'px'; - } - - onScroll() { - if (this.data.length === 0) return; - - const scrollTop = this.container.scrollTop; - const containerHeight = this.container.clientHeight; - - // Find start index - let currentY = 0; - let startIndex = 0; - for (let i = 0; i < this.data.length; i++) { - const h = this.heightCache.get(i) || this.rowHeight; - if (currentY + h > scrollTop) { - startIndex = i; - break; - } - currentY += h; - } - - // Find end index - let endIndex = startIndex; - let visibleHeight = 0; - for (let i = startIndex; i < this.data.length; i++) { - const h = this.heightCache.get(i) || this.rowHeight; - visibleHeight += h; - endIndex = i; - if (visibleHeight > containerHeight) break; - } - - const start = Math.max(0, startIndex - this.buffer); - const end = Math.min(this.data.length - 1, endIndex + this.buffer); - - this.renderRange(start, end, currentY); - } - - renderRange(start, end, startY) { - // Calculate offset for start item - let topOffset = 0; - for(let i=0; i 1) { - this.heightCache.set(i, actualHeight); - // If height changed significantly, we might need to adjust total height - // But for performance, maybe just do it on next scroll or rarely? - // For now, let's keep it simple. If we update inline style top, we need to know exact previous heights. - } - } - - el.style.top = currentTop + 'px'; - currentTop += (this.heightCache.get(i) || this.rowHeight); - } - - // Cleanup - Array.from(this.content.children).forEach(child => { - if (child.classList.contains('empty')) return; - const idx = parseInt(child.getAttribute('data-index')); - if (!newKeys.has(idx)) { - child.remove(); - } - }); - - this.updateTotalHeight(); - } - scrollToTime(timestamp) { - const idx = this.data.findIndex(item => item.ts >= timestamp); - if (idx !== -1) { - this.scrollToIndex(idx); - } - } - - scrollToIndex(index) { - let top = 0; - for(let i=0; i item.t >= timestamp); + if (idx === -1) return; + // Ensure all messages up to target are rendered + while (this.rendered <= idx) { + this.renderBatch(); + } + const el = this.list.children[idx]; + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('highlight'); + setTimeout(() => el.classList.remove('highlight'), 2500); + } + } + + scrollToIndex(index) { + while (this.rendered <= index) { + this.renderBatch(); + } + const el = this.list.children[index]; + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } - this.container.scrollTop = top; } } `; @@ -3447,10 +3513,14 @@ class ExportService { current: 20, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const MEDIA_CONCURRENCY = 6 + let mediaExported = 0 await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -3464,6 +3534,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -3478,13 +3560,27 @@ class ExportService { current: 40, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 40, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -3535,43 +3631,23 @@ class ExportService {
-

${this.escapeHtml(sessionInfo.displayName)} 的聊天记录

+

${this.escapeHtml(sessionInfo.displayName)}

- 导出时间:${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))} - 消息数量:${sortedMessages.length} - 会话类型:${isGroup ? '群聊' : '私聊'} + ${sortedMessages.length} 条消息 + ${isGroup ? '群聊' : '私聊'} + ${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))}
-
- - -
-
- - -
-
- - -
-
- - -
+ + +
共 ${sortedMessages.length} 条
- -
+
@@ -3584,7 +3660,23 @@ class ExportService { window.WEFLOW_DATA = [ `); - // Write messages in chunks + // Pre-build avatar HTML lookup to avoid per-message rebuilds + const avatarHtmlCache = new Map() + const getAvatarHtml = (username: string, name: string): string => { + const cached = avatarHtmlCache.get(username) + if (cached !== undefined) return cached + const avatarData = avatarMap.get(username) + const html = avatarData + ? `${this.escapeAttribute(name)}` + : `${this.escapeHtml(this.getAvatarFallback(name))}` + avatarHtmlCache.set(username, html) + return html + } + + // Write messages in buffered chunks + const WRITE_BATCH = 100 + let writeBuf: string[] = [] + for (let i = 0; i < sortedMessages.length; i++) { const msg = sortedMessages[i] const mediaKey = `${msg.localType}_${msg.localId}` @@ -3597,10 +3689,8 @@ class ExportService { : (isGroup ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) : (sessionInfo.displayName || sessionId)) - const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername) - const avatarHtml = avatarData - ? `${this.escapeAttribute(senderName)}` - : `${this.escapeHtml(this.getAvatarFallback(senderName))}` + + const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName) const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) @@ -3634,14 +3724,7 @@ class ExportService { ? `
${this.escapeHtml(senderName)}
` : '' const timeHtml = `
${this.escapeHtml(timeText)}
` - const messageBody = ` - ${timeHtml} - ${senderNameHtml} -
- ${mediaHtml} - ${textHtml} -
- ` + const messageBody = `${timeHtml}${senderNameHtml}
${mediaHtml}${textHtml}
` // Compact JSON object const itemObj = { @@ -3652,8 +3735,15 @@ class ExportService { b: messageBody // body HTML } - const jsonStr = JSON.stringify(itemObj) - await writePromise(jsonStr + (i < sortedMessages.length - 1 ? ',\n' : '\n')) + writeBuf.push(JSON.stringify(itemObj)) + + // Flush buffer periodically + if (writeBuf.length >= WRITE_BATCH || i === sortedMessages.length - 1) { + const isLast = i === sortedMessages.length - 1 + const chunk = writeBuf.join(',\n') + (isLast ? '\n' : ',\n') + await writePromise(chunk) + writeBuf = [] + } // Report progress occasionally if ((i + 1) % 500 === 0) { @@ -3676,10 +3766,9 @@ class ExportService { const timeInput = document.getElementById('timeInput') const jumpBtn = document.getElementById('jumpBtn') const resultCount = document.getElementById('resultCount') - const themeSelect = document.getElementById('themeSelect') const imagePreview = document.getElementById('imagePreview') const imagePreviewTarget = document.getElementById('imagePreviewTarget') - const container = document.getElementById('virtualScrollContainer') + const container = document.getElementById('scrollContainer') let imageZoom = 1 // Initial Data @@ -3701,7 +3790,7 @@ class ExportService { \`; }; - const scroller = new VirtualScroller(container, [], currentList, renderItem); + const renderer = new ChunkedRenderer(container, currentList, renderItem); const updateCount = () => { resultCount.textContent = \`共 \${currentList.length} 条\` @@ -3716,14 +3805,11 @@ class ExportService { if (!keyword) { currentList = allData; } else { - // Simplified search: check raw html content (contains body text and sender name) - // Ideally we should search raw text, but we only have pre-rendered HTML in JSON 'b' (body) - // 'b' contains message content and sender name. currentList = allData.filter(item => { return item.b.toLowerCase().includes(keyword); }); } - scroller.setData(currentList); + renderer.setData(currentList); updateCount(); }, 300); }) @@ -3733,21 +3819,7 @@ class ExportService { const value = timeInput.value if (!value) return const target = Math.floor(new Date(value).getTime() / 1000) - // Find in current list - scroller.scrollToTime(target); - }) - - // Theme Logic - const applyTheme = (value) => { - document.body.setAttribute('data-theme', value) - localStorage.setItem('weflow-export-theme', value) - } - const storedTheme = localStorage.getItem('weflow-export-theme') || 'cloud-dancer' - themeSelect.value = storedTheme - applyTheme(storedTheme) - - themeSelect.addEventListener('change', (event) => { - applyTheme(event.target.value) + renderer.scrollToTime(target); }) // Image Preview (Delegation) @@ -3788,7 +3860,6 @@ class ExportService { }) updateCount() - console.log('WeFlow Export Loaded', allData.length); `); @@ -3811,6 +3882,77 @@ class ExportService { } } + /** + * 获取导出前的预估统计信息 + */ + async getExportStats( + sessionIds: string[], + options: ExportOptions + ): Promise<{ + totalMessages: number + voiceMessages: number + cachedVoiceCount: number + needTranscribeCount: number + mediaMessages: number + estimatedSeconds: number + sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> + }> { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) { + return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } + } + const cleanedMyWxid = conn.cleanedWxid + const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] + let totalMessages = 0 + let voiceMessages = 0 + let cachedVoiceCount = 0 + let mediaMessages = 0 + + for (const sessionId of sessionIds) { + const sessionInfo = await this.getContactInfo(sessionId) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const msgs = collected.rows + const voiceMsgs = msgs.filter(m => m.localType === 34) + const mediaMsgs = msgs.filter(m => { + const t = m.localType + return (t === 3) || (t === 47) || (t === 43) || (t === 34) + }) + + // 检查已缓存的转写数量 + let cached = 0 + for (const msg of voiceMsgs) { + if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) { + cached++ + } + } + + totalMessages += msgs.length + voiceMessages += voiceMsgs.length + cachedVoiceCount += cached + mediaMessages += mediaMsgs.length + sessionsStats.push({ + sessionId, + displayName: sessionInfo.displayName, + totalCount: msgs.length, + voiceCount: voiceMsgs.length + }) + } + + const needTranscribeCount = voiceMessages - cachedVoiceCount + // 预估:每条语音转文字约 2 秒 + const estimatedSeconds = needTranscribeCount * 2 + + return { + totalMessages, + voiceMessages, + cachedVoiceCount, + needTranscribeCount, + mediaMessages, + estimatedSeconds, + sessions: sessionsStats + } + } + /** * 批量导出多个会话 */ @@ -3850,7 +3992,17 @@ class ExportService { await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => { const sessionInfo = await this.getContactInfo(sessionId) - onProgress?.({ + // 创建包装后的进度回调,自动附加会话级信息 + const sessionProgress = (progress: ExportProgress) => { + onProgress?.({ + ...progress, + current: completedCount, + total: sessionIds.length, + currentSession: sessionInfo.displayName + }) + } + + sessionProgress({ current: completedCount, total: sessionIds.length, currentSession: sessionInfo.displayName, @@ -3874,15 +4026,15 @@ class ExportService { let result: { success: boolean; error?: string } if (options.format === 'json') { - result = await this.exportSessionToDetailedJson(sessionId, outputPath, options) + result = await this.exportSessionToDetailedJson(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') { - result = await this.exportSessionToChatLab(sessionId, outputPath, options) + result = await this.exportSessionToChatLab(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'excel') { - result = await this.exportSessionToExcel(sessionId, outputPath, options) + result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'txt') { - result = await this.exportSessionToTxt(sessionId, outputPath, options) + result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'html') { - result = await this.exportSessionToHtml(sessionId, outputPath, options) + result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 371c9b9..e66004c 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -403,6 +403,15 @@ export interface ElectronAPI { onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void } export: { + getExportStats: (sessionIds: string[], options: any) => Promise<{ + totalMessages: number + voiceMessages: number + cachedVoiceCount: number + needTranscribeCount: number + mediaMessages: number + estimatedSeconds: number + sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> + }> exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{ success: boolean successCount?: number @@ -494,7 +503,10 @@ export interface ExportProgress { current: number total: number currentSession: string - phase: 'preparing' | 'exporting' | 'writing' | 'complete' + phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' + phaseProgress?: number + phaseTotal?: number + phaseLabel?: string } export interface WxidInfo {