diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 9cf81b6c..8366dbb4 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -5959,9 +5959,7 @@ class ChatService { if (cachePath) { return join(cachePath, 'Voices') } - // 回退到默认目录 - const documentsPath = app.getPath('documents') - return join(documentsPath, 'WeFlow', 'Voices') + return join(this.getDefaultDocumentsBaseDir(), 'Voices') } private getEmojiCacheDir(): string { @@ -5969,9 +5967,19 @@ class ChatService { if (cachePath) { return join(cachePath, 'Emojis') } - // 回退到默认目录 - const documentsPath = app.getPath('documents') - return join(documentsPath, 'WeFlow', 'Emojis') + return join(this.getDefaultDocumentsBaseDir(), 'Emojis') + } + + private getDefaultDocumentsBaseDir(): string { + const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() + if (workerUserDataPath) { + return join(workerUserDataPath, 'documents-fallback', 'WeFlow') + } + const documentsPath = app?.getPath?.('documents') + if (documentsPath) { + return join(documentsPath, 'WeFlow') + } + return join(this.configService.getCacheBasePath(), '..') } clearCaches(options?: { includeMessages?: boolean; includeContacts?: boolean; includeEmojis?: boolean }): { success: boolean; error?: string } { @@ -7373,7 +7381,7 @@ class ChatService { /** 获取持久化转写缓存文件路径 */ private getTranscriptCachePath(): string { const cachePath = this.configService.get('cachePath') - const base = cachePath || join(app.getPath('documents'), 'WeFlow') + const base = cachePath || this.getDefaultDocumentsBaseDir() return join(base, 'Voices', 'transcripts.json') } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 2512f720..dabaddcb 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -88,6 +88,7 @@ const MESSAGE_TYPE_MAP: Record = { export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + streamingHtml?: boolean contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' dateRange?: { start: number; end: number } | null senderUsername?: string @@ -227,6 +228,27 @@ interface ExportAggregatedSessionStatsCacheEntry { data: Record } +interface HtmlExportChunkManifestItem { + file: string + count: number + startIndex: number + endIndex: number + startTime?: number + endTime?: number +} + +interface HtmlExportManifest { + version: 1 + generatedBy: string + sessionId: string + sessionName: string + isGroup: boolean + totalMessages: number + exportedAt: number + manifestPath: string + chunks: HtmlExportChunkManifestItem[] +} + // 并发控制:限制同时执行的 Promise 数量 async function parallelLimit( items: T[], @@ -7991,6 +8013,25 @@ class ExportService { this.renderBatch(); } + onDataAppended() { + if (this.data.length === 0) { + this.list.innerHTML = '
暂无消息
'; + return; + } + if (this.rendered === 0 && this.list.children.length === 0) { + this.renderBatch(); + } + while ( + this.rendered < this.data.length && + ( + this.container.scrollHeight <= this.container.clientHeight + 240 || + this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 1200 + ) + ) { + this.renderBatch(); + } + } + scrollToTime(timestamp) { const idx = this.data.findIndex(item => item.t >= timestamp); if (idx === -1) return; @@ -8019,6 +8060,324 @@ class ExportService { `; } + private getStreamingHtmlChunkSize(): number { + return 1000 + } + + private getStreamingHtmlBundlePaths(outputPath: string): { + bundleDirName: string + bundleDir: string + manifestPath: string + chunksDir: string + manifestRelativePath: string + } { + const htmlBaseName = path.basename(outputPath, path.extname(outputPath)) + const bundleDirName = `${htmlBaseName}.stream` + const bundleDir = path.join(path.dirname(outputPath), bundleDirName) + return { + bundleDirName, + bundleDir, + manifestPath: path.join(bundleDir, 'manifest.json'), + chunksDir: path.join(bundleDir, 'chunks'), + manifestRelativePath: path.posix.join(bundleDirName, 'manifest.json') + } + } + + private buildStreamingHtmlDocument( + sessionName: string, + totalMessages: number, + isGroup: boolean, + exportedAt: number, + htmlStyles: string, + manifestRelativePath: string + ): string { + return ` + + + + + ${this.escapeHtml(sessionName)} - 聊天记录 + + + +
+
+

${this.escapeHtml(sessionName)}

+
+ ${totalMessages} 条消息 + ${isGroup ? '群聊' : '私聊'} + ${this.escapeHtml(this.formatTimestamp(exportedAt))} +
+
+ + + +
+ 正在加载... +
+
+
+ +
+
+ +
+ 预览 +
+ + + + + +` + } + /** * 导出单个会话为 HTML 格式 */ @@ -8246,63 +8605,8 @@ class ExportService { exportedMessages: 0 }) - // ================= BEGIN STREAM WRITING ================= const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup) const htmlStyles = this.loadExportHtmlStyles() - const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' }) - - const writePromise = (str: string) => { - return new Promise((resolve, reject) => { - this.throwIfStopRequested(control) - if (!stream.write(str)) { - stream.once('drain', resolve) - } else { - resolve() - } - }) - } - - await writePromise(` - - - - - ${this.escapeHtml(sessionInfo.displayName)} - 聊天记录 - - - -
-
-

${this.escapeHtml(sessionInfo.displayName)}

-
- ${sortedMessages.length} 条消息 - ${isGroup ? '群聊' : '私聊'} - ${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))} -
-
- - - -
- 共 ${sortedMessages.length} 条 -
-
-
- -
- -
- -
- 预览 -
- - -