diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index 02e025ef..46c6c752 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -34,7 +34,6 @@ import { createDatabaseManagerAdapter } from '@openchatlab/node-runtime' import { registerSharedRoutes } from '@openchatlab/http-routes' import { MergeSessionCache } from '../../../merger/merge-cache' import { registerImportRoutes } from './import' -import { registerMergeRoutes } from './merge' import { registerCacheRoutes } from './cache' import { getVersion } from '../../../version' @@ -95,6 +94,14 @@ export function registerWebRoutes( const resolvedPathProvider = options?.pathProvider ?? fallbackPathProvider const ai = options?.aiContext + + const cliStreamImport = async (dm: typeof dbManager, filePath: string) => { + const { streamImport } = await import('../../../import/stream-import') + const result = await streamImport(dm, filePath) + if (!result.sessionId) throw new Error('Import succeeded but no sessionId returned') + return { sessionId: result.sessionId } + } + registerSharedRoutes( server, { @@ -103,6 +110,10 @@ export function registerWebRoutes( pathProvider: resolvedPathProvider, getVersion, nativeBinding: options?.nativeBinding, + ...(mergeCache && { + mergeSessionCache: mergeCache, + streamImport: cliStreamImport, + }), ...(ai && { aiDataDir: ai.aiDataDir, conversationManager: ai.convManager, @@ -118,9 +129,6 @@ export function registerWebRoutes( // CLI-specific routes not yet migrated to @openchatlab/http-routes registerImportRoutes(server, dbManager) - if (mergeCache) { - registerMergeRoutes(server, dbManager, mergeCache) - } registerCacheRoutes(server, resolvedPathProvider) server.get('/_web/system/check-update', async () => { diff --git a/apps/cli/src/merger/merge-cache.ts b/apps/cli/src/merger/merge-cache.ts index a6f9dbf7..cdee8d28 100644 --- a/apps/cli/src/merger/merge-cache.ts +++ b/apps/cli/src/merger/merge-cache.ts @@ -1,103 +1 @@ -/** - * Server-side merge session cache. - * - * Manages temporary databases for the multi-file merge workflow. - * Each uploaded/parsed file gets a temp DB handle that persists - * across HTTP requests until merge completes or cache is cleared. - */ - -import * as fs from 'fs' -import * as path from 'path' -import * as crypto from 'crypto' -import type { PathProvider, DatabaseAdapter } from '@openchatlab/core' -import { - openBetterSqliteDatabase, - TempDbReader, - deleteTempDatabase, - cleanupTempDatabases, -} from '@openchatlab/node-runtime' - -interface CacheEntry { - tempDbPath: string - filename: string - createdAt: number -} - -export class MergeSessionCache { - private cache = new Map() - private tempDir: string - private nativeBinding?: string - - constructor(pathProvider: PathProvider, options?: { nativeBinding?: string }) { - this.tempDir = pathProvider.getTempDir() - this.nativeBinding = options?.nativeBinding - if (!fs.existsSync(this.tempDir)) { - fs.mkdirSync(this.tempDir, { recursive: true }) - } - } - - generateTempDbPath(filename: string): string { - const ts = Date.now() - const rand = Math.random().toString(36).slice(2, 8) - const safeName = filename.replace(/[/\\?%*:|"<>]/g, '_').slice(0, 40) - return path.join(this.tempDir, `merge_${safeName}_${ts}_${rand}.db`) - } - - /** Store a parsed file's temp DB and return a handle for subsequent requests. */ - store(filename: string, tempDbPath: string): string { - const handle = crypto.randomUUID() - this.cache.set(handle, { tempDbPath, filename, createdAt: Date.now() }) - return handle - } - - /** Open a TempDbReader for a given handle. Caller must close the reader. */ - openReader(handle: string): { reader: TempDbReader; filename: string } | null { - const entry = this.cache.get(handle) - if (!entry || !fs.existsSync(entry.tempDbPath)) return null - const adapter = openBetterSqliteDatabase(entry.tempDbPath, { - readonly: true, - nativeBinding: this.nativeBinding, - }) - return { reader: new TempDbReader(adapter), filename: entry.filename } - } - - /** Create a writable temp DB for streaming parse. */ - createTempDatabase(filename: string): { db: DatabaseAdapter; tempDbPath: string } { - const tempDbPath = this.generateTempDbPath(filename) - const adapter = openBetterSqliteDatabase(tempDbPath, { nativeBinding: this.nativeBinding }) - return { db: adapter, tempDbPath } - } - - /** Remove a single entry and its temp DB file. */ - delete(handle: string): void { - const entry = this.cache.get(handle) - if (entry) { - deleteTempDatabase(entry.tempDbPath) - this.cache.delete(handle) - } - } - - /** Clear all entries and delete their temp DB files. */ - clear(): void { - for (const entry of this.cache.values()) { - deleteTempDatabase(entry.tempDbPath) - } - this.cache.clear() - } - - /** Cleanup orphan temp DBs from previous runs. */ - cleanupOrphans(): void { - cleanupTempDatabases(this.tempDir) - } - - /** Auto-cleanup entries older than maxAge (default 1 hour). */ - cleanupExpired(maxAgeMs = 3600_000): void { - const now = Date.now() - for (const [handle, entry] of this.cache) { - if (now - entry.createdAt > maxAgeMs) { - deleteTempDatabase(entry.tempDbPath) - this.cache.delete(handle) - } - } - } -} +export { MergeSessionCache } from '@openchatlab/node-runtime' diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts index 69f38122..bc80749f 100644 --- a/apps/desktop/main/internal-api.ts +++ b/apps/desktop/main/internal-api.ts @@ -14,13 +14,19 @@ import { randomBytes, createHmac, timingSafeEqual } from 'crypto' import { ipcMain } from 'electron' import Fastify, { type FastifyInstance, type FastifyError, type FastifyRequest, type FastifyReply } from 'fastify' import type { PathProvider } from '@openchatlab/core' +import { CHAT_DB_TABLES } from '@openchatlab/core' import { DatabaseManager, createDatabaseManagerAdapter, LLMConfigStore, CustomProviderStore, CustomModelStore, + MergeSessionCache, + openBetterSqliteDatabase, + streamingImport, } from '@openchatlab/node-runtime' +import type { StreamImportDeps } from '@openchatlab/node-runtime' +import multipart from '@fastify/multipart' import type { ConfigStorage } from '@openchatlab/node-runtime' import { registerSharedRoutes, ApiError, ApiErrorCode, errorResponse, serverError } from '@openchatlab/http-routes' import type { HttpRouteContext } from '@openchatlab/http-routes' @@ -37,6 +43,7 @@ export interface InternalEndpoint { let server: FastifyInstance | null = null let endpoint: InternalEndpoint | null = null let dbManager: DatabaseManager | null = null +let mergeCache: MergeSessionCache | null = null const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50 MB @@ -85,11 +92,47 @@ export async function startInternalServer(pathProvider: PathProvider): Promise { + const dbDir = pathProvider.getDatabaseDir() + if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true }) + + const deps: StreamImportDeps = { + openDatabase(sessionId: string) { + const dbPath = path.join(dbDir, `${sessionId}.db`) + const adapter = openBetterSqliteDatabase(dbPath, { readonly: false }) + adapter.exec(CHAT_DB_TABLES) + return adapter + }, + deleteDatabase(sessionId: string) { + const dbPath = path.join(dbDir, `${sessionId}.db`) + for (const suffix of ['', '-wal', '-shm']) { + try { + if (fs.existsSync(dbPath + suffix)) fs.unlinkSync(dbPath + suffix) + } catch { + /* ignore */ + } + } + }, + onProgress() { + /* no progress for merge-triggered import */ + }, + } + const result = await streamingImport(filePath, deps) + if (!result.sessionId) throw new Error('Import succeeded but no sessionId returned') + return { sessionId: result.sessionId } + } + const ctx: HttpRouteContext = { dbManager: newDbManager, sessionAdapter, pathProvider, getVersion: () => app.getVersion(), + mergeSessionCache: newMergeCache, + streamImport: electronStreamImport, aiDataDir, conversationManager: getConversationManager(), assistantManager: getAssistantManager(), @@ -101,6 +144,8 @@ export async function startInternalServer(pathProvider: PathProvider): Promise { } catch (err) { console.error('[InternalAPI] Error closing server:', err) } finally { + try { + mergeCache?.clear() + } catch { + /* best-effort */ + } try { dbManager?.closeAll() } catch { @@ -200,6 +252,7 @@ export async function stopInternalServer(): Promise { server = null endpoint = null dbManager = null + mergeCache = null console.log('[InternalAPI] Server stopped') } } diff --git a/apps/desktop/main/ipc/chat.ts b/apps/desktop/main/ipc/chat.ts index 09501ea1..559b31d4 100644 --- a/apps/desktop/main/ipc/chat.ts +++ b/apps/desktop/main/ipc/chat.ts @@ -13,7 +13,6 @@ import { detectFormat, findEntryFileInDirectory, scanMultiChatFile, type ParsePr import * as parser from '../parser' import type { IpcContext } from './types' import { CURRENT_SCHEMA_VERSION, getPendingMigrationInfos } from '../database/migrations' -import { exportSessionToTempFile, cleanupTempExportFiles } from '../merger' import { t } from '../i18n' export function registerChatHandlers(ctx: IpcContext): void { @@ -384,30 +383,4 @@ export function registerChatHandlers(ctx: IpcContext): void { return {} } }) - - // ==================== 临时导出(合并用) ==================== - - ipcMain.handle('chat:exportSessionsToTempFiles', async (_, sessionIds: string[]) => { - try { - const tempFiles: string[] = [] - for (const sessionId of sessionIds) { - const tempPath = await exportSessionToTempFile(sessionId) - tempFiles.push(tempPath) - } - return { success: true, tempFiles } - } catch (error) { - console.error('[IpcMain] Failed to export session:', error) - return { success: false, error: String(error), tempFiles: [] } - } - }) - - ipcMain.handle('chat:cleanupTempExportFiles', async (_, filePaths: string[]) => { - try { - cleanupTempExportFiles(filePaths) - return { success: true } - } catch (error) { - console.error('[IpcMain] Failed to clean up temp files:', error) - return { success: false, error: String(error) } - } - }) } diff --git a/apps/desktop/main/ipc/merge.ts b/apps/desktop/main/ipc/merge.ts deleted file mode 100644 index 47714381..00000000 --- a/apps/desktop/main/ipc/merge.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * 合并功能 IPC 处理器 - */ - -import { ipcMain } from 'electron' -import * as worker from '../worker/workerManager' -import * as merger from '../merger' -import { deleteTempDatabase, cleanupAllTempDatabases } from '../merger/tempCache' -import type { ParseProgress } from '../parser' -import type { MergeParams } from '../../../../src/types/format' -import type { IpcContext } from './types' - -// ==================== 临时数据库缓存 ==================== -// 用于合并功能:缓存文件对应的临时数据库路径 -// 这样用户删除本地文件后仍然可以进行合并(数据已存入临时数据库) -const tempDbCache = new Map() - -/** - * 清理指定文件的缓存(删除临时数据库) - */ -function clearTempDbCache(filePath: string): void { - const tempDbPath = tempDbCache.get(filePath) - if (tempDbPath) { - deleteTempDatabase(tempDbPath) - tempDbCache.delete(filePath) - } -} - -/** - * 清理所有缓存(删除所有临时数据库) - */ -export function cleanupTempDbs(): void { - for (const tempDbPath of tempDbCache.values()) { - deleteTempDatabase(tempDbPath) - } - tempDbCache.clear() - console.log('[IpcMain] Cleaned up all temp databases') -} - -/** - * 初始化合并模块(清理残留临时数据库) - */ -export function initMergeModule(): void { - cleanupAllTempDatabases() -} - -/** - * 注册合并功能 IPC 处理器 - */ -export function registerMergeHandlers(ctx: IpcContext): void { - const { win } = ctx - - /** - * 解析文件获取基本信息(用于合并预览) - * 使用流式解析,数据写入临时数据库,避免内存溢出 - */ - ipcMain.handle('merge:parseFileInfo', async (_, filePath: string) => { - try { - // 使用流式解析,写入临时数据库 - const result = await worker.streamParseFileInfo(filePath, (progress: ParseProgress) => { - // 可选:发送进度到渲染进程 - win.webContents.send('merge:parseProgress', { - filePath, - progress, - }) - }) - - // 缓存临时数据库路径(用于后续合并) - // 这样即使用户删除本地文件,也能继续合并(数据已在临时数据库中) - if (result.tempDbPath) { - tempDbCache.set(filePath, result.tempDbPath) - console.log(`[IpcMain] Cached temp database: ${filePath} -> ${result.tempDbPath}`) - } - - // 返回基本信息 - return { - name: result.name, - format: result.format, - platform: result.platform, - messageCount: result.messageCount, - memberCount: result.memberCount, - fileSize: result.fileSize, - } - } catch (error) { - console.error('Failed to parse file info:', error) - throw error - } - }) - - /** - * 检测合并冲突(使用临时数据库) - */ - ipcMain.handle('merge:checkConflicts', async (_, filePaths: string[]) => { - try { - return merger.checkConflictsWithTempDb(filePaths, tempDbCache) - } catch (error) { - console.error('Failed to detect conflicts:', error) - throw error - } - }) - - /** - * 执行合并(使用临时数据库) - */ - ipcMain.handle('merge:mergeFiles', async (_, params: MergeParams) => { - try { - const result = await merger.mergeFilesWithTempDb(params, tempDbCache) - // 合并完成后清理缓存 - if (result.success) { - for (const filePath of params.filePaths) { - clearTempDbCache(filePath) - } - } - return result - } catch (error) { - console.error('Failed to merge:', error) - return { success: false, error: String(error) } - } - }) - - /** - * 清理合并缓存(用于用户移除文件时) - */ - ipcMain.handle('merge:clearCache', async (_, filePath?: string) => { - if (filePath) { - clearTempDbCache(filePath) - } else { - cleanupTempDbs() - } - return true - }) -} diff --git a/apps/desktop/main/ipcMain.ts b/apps/desktop/main/ipcMain.ts index 5ce885d6..f95e67ec 100644 --- a/apps/desktop/main/ipcMain.ts +++ b/apps/desktop/main/ipcMain.ts @@ -8,7 +8,6 @@ import type { IpcContext } from './ipc/types' // 导入各功能模块 import { registerWindowHandlers } from './ipc/window' import { registerChatHandlers } from './ipc/chat' -import { registerMergeHandlers, initMergeModule, cleanupTempDbs } from './ipc/merge' import { registerAIHandlers } from './ipc/ai' import { registerMessagesHandlers } from './ipc/messages' import { registerCacheHandlers } from './ipc/cache' @@ -26,9 +25,6 @@ import * as worker from './worker/workerManager' const mainIpcMain = (win: BrowserWindow) => { console.log('[IpcMain] Registering IPC handlers...') - // 初始化合并模块(清理残留的临时数据库) - initMergeModule() - // 初始化 Worker try { worker.initWorker() @@ -42,7 +38,6 @@ const mainIpcMain = (win: BrowserWindow) => { // 注册各模块的处理器 registerWindowHandlers(context) registerChatHandlers(context) - registerMergeHandlers(context) registerAIHandlers(context) registerMessagesHandlers(context) registerCacheHandlers(context) @@ -62,10 +57,7 @@ const mainIpcMain = (win: BrowserWindow) => { export const cleanup = () => { console.log('[IpcMain] Cleaning up resources...') try { - // 关闭 Worker worker.closeWorker() - // 清理临时数据库 - cleanupTempDbs() } catch (error) { console.error('[IpcMain] Error during cleanup:', error) } @@ -81,8 +73,6 @@ export const cleanupAsync = async () => { await cleanupApiServer() // 等待 Worker 完全关闭 await worker.closeWorkerAsync() - // 清理临时数据库 - cleanupTempDbs() console.log('[IpcMain] Cleanup completed') } catch (error) { console.error('[IpcMain] Error during async cleanup:', error) diff --git a/apps/desktop/main/merger/index.ts b/apps/desktop/main/merger/index.ts deleted file mode 100644 index af60577d..00000000 --- a/apps/desktop/main/merger/index.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Chat record merger module — Electron adapter layer. - * Delegates pure algorithms to @openchatlab/core and orchestration to @openchatlab/node-runtime. - * Keeps only platform-specific I/O: file parsing, temp DB access, session DB export, importData. - */ - -import * as fs from 'fs' -import * as path from 'path' -import { checkConflictsFromSources, buildMergedOutput, serializeChatLabToJsonl } from '@openchatlab/node-runtime' -import type { ChatLabOutput } from '@openchatlab/node-runtime' -import { importData } from '../database/core' -import { TempDbReader } from './tempCache' -import { getPathProvider } from '../path-context' -import type { ChatPlatform, ChatType } from '../../../../src/types/base' -import type { - ChatLabFormat, - ChatLabMessage, - ConflictCheckResult, - MergeParams, - MergeResult, -} from '../../../../src/types/format' - -function getDefaultOutputDir(): string { - return getPathProvider().getDownloadsDir() -} - -function ensureOutputDir(dir: string): void { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } -} - -function generateOutputFilename(name: string, format: 'json' | 'jsonl' = 'json'): string { - const date = new Date().toISOString().slice(0, 10).replace(/-/g, '') - const safeName = name.replace(/[/\\?%*:|"<>]/g, '_') - return `${safeName}_merged_${date}.${format}` -} - -// ==================== ChatLabOutput → ChatLabFormat conversion ==================== - -function chatLabOutputToFormat(data: ChatLabOutput): ChatLabFormat { - return { - chatlab: data.chatlab, - meta: { - ...data.meta, - platform: data.meta.platform as ChatPlatform, - type: data.meta.type as ChatType, - }, - members: data.members.map((m) => ({ - platformId: m.platformId, - accountName: m.accountName || m.platformId, - groupNickname: m.groupNickname, - avatar: m.avatar, - })), - messages: data.messages.map((msg) => ({ - sender: msg.sender, - accountName: msg.accountName || msg.sender, - groupNickname: msg.groupNickname, - timestamp: msg.timestamp, - type: msg.type, - content: msg.content ?? null, - })), - } -} - -// ==================== TempDB-based merge (delegates to node-runtime) ==================== - -export async function checkConflictsWithTempDb( - filePaths: string[], - tempDbCache: Map -): Promise { - const readers: TempDbReader[] = [] - try { - const dataSources: Array<{ source: ReturnType; filename: string }> = [] - for (const filePath of filePaths) { - const tempDbPath = tempDbCache.get(filePath) - if (!tempDbPath) { - throw new Error(`Temp database not found: ${path.basename(filePath)}`) - } - const reader = new TempDbReader(tempDbPath) - readers.push(reader) - dataSources.push({ source: reader.toDataSource(), filename: path.basename(filePath) }) - } - - return checkConflictsFromSources(dataSources) - } finally { - for (const reader of readers) { - reader.close() - } - } -} - -export async function mergeFilesWithTempDb( - params: MergeParams, - tempDbCache: Map -): Promise { - const { filePaths, outputName, outputDir, outputFormat = 'json', andAnalyze } = params - - console.log('[Merger] mergeFilesWithTempDb: Starting merge') - - const readers: TempDbReader[] = [] - try { - const dataSources: Array<{ source: ReturnType; filename: string }> = [] - for (const filePath of filePaths) { - const tempDbPath = tempDbCache.get(filePath) - if (!tempDbPath) { - throw new Error(`Temp database not found: ${path.basename(filePath)}`) - } - const reader = new TempDbReader(tempDbPath) - readers.push(reader) - dataSources.push({ source: reader.toDataSource(), filename: path.basename(filePath) }) - } - - const result = buildMergedOutput(dataSources, outputName) - const chatLabData = result.chatLabData - - const targetDir = outputDir || getDefaultOutputDir() - ensureOutputDir(targetDir) - const filename = generateOutputFilename(outputName, outputFormat) - const outputPath = path.join(targetDir, filename) - - if (outputFormat === 'jsonl') { - const writeStream = fs.createWriteStream(outputPath, { encoding: 'utf-8' }) - for (const line of serializeChatLabToJsonl(chatLabData)) { - writeStream.write(line + '\n') - } - writeStream.end() - await new Promise((resolve, reject) => { - writeStream.on('finish', resolve) - writeStream.on('error', reject) - }) - } else { - const fileData: ChatLabFormat = chatLabOutputToFormat(chatLabData) - fs.writeFileSync(outputPath, JSON.stringify(fileData, null, 2), 'utf-8') - } - - console.log(`[Merger] Messages after merge: ${chatLabData.messages.length}`) - - let sessionId: string | undefined - if (andAnalyze) { - const fmt = chatLabOutputToFormat(chatLabData) - sessionId = importData({ - meta: { - name: fmt.meta.name, - platform: fmt.meta.platform, - type: fmt.meta.type, - groupId: fmt.meta.groupId, - groupAvatar: fmt.meta.groupAvatar, - }, - members: fmt.members.map((m) => ({ - platformId: m.platformId, - accountName: m.accountName, - groupNickname: m.groupNickname, - avatar: m.avatar, - })), - messages: fmt.messages.map((msg) => ({ - senderPlatformId: msg.sender, - senderAccountName: msg.accountName, - senderGroupNickname: msg.groupNickname, - timestamp: msg.timestamp, - type: msg.type, - content: msg.content, - })), - }) - } - - return { success: true, outputPath, sessionId } - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : 'Merge failed', - } - } finally { - for (const reader of readers) { - reader.close() - } - } -} - -// ==================== Session DB export ==================== - -import Database from 'better-sqlite3' -import { getDbPath } from '../database/core' -import { getExportSessionData } from '@openchatlab/core' -import { wrapAsDatabaseAdapter } from '../worker/core' - -export async function exportSessionToTempFile(sessionId: string): Promise { - const dbPath = getDbPath(sessionId) - if (!fs.existsSync(dbPath)) { - throw new Error(`Session database not found: ${sessionId}`) - } - - const db = new Database(dbPath, { readonly: true }) - - try { - const data = getExportSessionData(wrapAsDatabaseAdapter(db)) - - const chatLabData: ChatLabFormat = { - chatlab: { - version: '0.0.1', - exportedAt: Math.floor(Date.now() / 1000), - generator: 'ChatLab Export', - description: `Exported from session: ${data.meta.name}`, - }, - meta: { - name: data.meta.name, - platform: data.meta.platform as ChatPlatform, - type: data.meta.type as ChatType, - groupId: data.meta.groupId, - groupAvatar: data.meta.groupAvatar, - }, - members: data.members, - messages: data.messages.map((msg) => ({ - ...msg, - type: msg.type as ChatLabMessage['type'], - })), - } - - const tempDir = path.join(getDefaultOutputDir(), '.chatlab_temp') - ensureOutputDir(tempDir) - const tempFilePath = path.join(tempDir, `export_${sessionId}_${Date.now()}.json`) - fs.writeFileSync(tempFilePath, JSON.stringify(chatLabData, null, 2), 'utf-8') - - console.log(`[Merger] Exporting session to temp file: ${tempFilePath}, message count: ${data.messages.length}`) - - return tempFilePath - } finally { - db.close() - } -} - -export function cleanupTempExportFiles(filePaths: string[]): void { - for (const filePath of filePaths) { - try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) - console.log(`[Merger] Cleaning up temp file: ${filePath}`) - } - } catch (err) { - console.error(`[Merger] Failed to clean up temp file: ${filePath}`, err) - } - } -} diff --git a/apps/desktop/main/merger/tempCache.ts b/apps/desktop/main/merger/tempCache.ts deleted file mode 100644 index b9b48fc2..00000000 --- a/apps/desktop/main/merger/tempCache.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Temp DB cache — Electron adapter layer. - * - * Keeps Electron-specific path helpers (getTempDir, generateTempDbPath). - * TempDbReader wraps the shared implementation with better-sqlite3 opening. - * TempDbWriter/cleanup delegate to shared @openchatlab/node-runtime. - */ - -import Database from 'better-sqlite3' -import * as path from 'path' -import { BetterSqliteAdapter, TEMP_DB_SCHEMA } from '@openchatlab/node-runtime' -import { - TempDbReader as SharedTempDbReader, - deleteTempDatabase as sharedDeleteTempDb, - cleanupTempDatabases as sharedCleanupTempDbs, -} from '@openchatlab/node-runtime' -import { getPathProvider } from '../path-context' -import { ensureDir } from '../paths' - -function getTempDir(): string { - const dir = getPathProvider().getTempDir() - ensureDir(dir) - return dir -} - -export function generateTempDbPath(sourceFilePath: string): string { - const timestamp = Date.now() - const random = Math.random().toString(36).substring(2, 8) - const baseName = path.basename(sourceFilePath, path.extname(sourceFilePath)) - const safeName = baseName.replace(/[/\\?%*:|"<>]/g, '_').substring(0, 50) - return path.join(getTempDir(), `merge_${safeName}_${timestamp}_${random}.db`) -} - -/** - * Create a temp database and return the raw better-sqlite3 handle. - * Used by streamImport.ts which wraps it in BetterSqliteAdapter. - */ -export function createTempDatabase(dbPath: string): Database.Database { - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - db.pragma('synchronous = NORMAL') - db.exec(TEMP_DB_SCHEMA) - return db -} - -/** - * Electron TempDbReader — opens the temp DB with better-sqlite3 - * and delegates to the shared TempDbReader via BetterSqliteAdapter. - */ -export class TempDbReader { - private inner: SharedTempDbReader - private dbPath: string - - constructor(dbPath: string) { - this.dbPath = dbPath - const db = new Database(dbPath, { readonly: true }) - db.pragma('journal_mode = WAL') - this.inner = new SharedTempDbReader(new BetterSqliteAdapter(db)) - } - - getMeta() { - return this.inner.getMeta() - } - - getMembers() { - return this.inner.getMembers() - } - - getMessageCount() { - return this.inner.getMessageCount() - } - - streamMessages(batchSize: number, callback: Parameters[1]) { - return this.inner.streamMessages(batchSize, callback) - } - - getAllMessages() { - return this.inner.getAllMessages() - } - - close() { - this.inner.close() - } - - getPath() { - return this.dbPath - } - - toDataSource() { - return this.inner.toDataSource() - } -} - -export function deleteTempDatabase(dbPath: string): void { - sharedDeleteTempDb(dbPath) -} - -export function cleanupAllTempDatabases(): void { - try { - sharedCleanupTempDbs(getTempDir()) - } catch (error) { - console.error('[TempCache] Failed to clean up temp databases:', error) - } -} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8a7d2e15..8e917bb0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,6 +17,7 @@ "@aptabase/electron": "^0.3.1", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", + "@fastify/multipart": "^10.0.0", "@node-rs/jieba": "^2.0.1", "better-sqlite3": "^12.4.6", "electron-updater": "^6.6.2", diff --git a/apps/desktop/preload/apis/chat.ts b/apps/desktop/preload/apis/chat.ts index 93fe8f09..bc051f4c 100644 --- a/apps/desktop/preload/apis/chat.ts +++ b/apps/desktop/preload/apis/chat.ts @@ -6,7 +6,6 @@ */ import { ipcRenderer } from 'electron' import type { ImportProgress } from '../../../../src/types/base' -import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../../../src/types/format' export const chatApi = { // ==================== 数据库迁移 ==================== @@ -79,16 +78,6 @@ export const chatApi = { ): Promise<{ success: boolean; newMessageCount: number; error?: string }> => ipcRenderer.invoke('chat:incrementalImport', sessionId, filePath), - // ==================== 临时导出(合并用) ==================== - - exportSessionsToTempFiles: ( - sessionIds: string[] - ): Promise<{ success: boolean; tempFiles: string[]; error?: string }> => - ipcRenderer.invoke('chat:exportSessionsToTempFiles', sessionIds), - - cleanupTempExportFiles: (filePaths: string[]): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('chat:cleanupTempExportFiles', filePaths), - // ==================== Demo ==================== importDemo: ( @@ -107,12 +96,3 @@ export const chatApi = { return () => ipcRenderer.removeListener('demo:progress', handler) }, } - -// Merge API -export const mergeApi = { - parseFileInfo: (filePath: string): Promise => ipcRenderer.invoke('merge:parseFileInfo', filePath), - checkConflicts: (filePaths: string[]): Promise => - ipcRenderer.invoke('merge:checkConflicts', filePaths), - mergeFiles: (params: MergeParams): Promise => ipcRenderer.invoke('merge:mergeFiles', params), - clearCache: (filePath?: string): Promise => ipcRenderer.invoke('merge:clearCache', filePath), -} diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index bbb9bf6e..9d292bd0 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -1,8 +1,6 @@ import { ElectronAPI } from '@electron-toolkit/preload' import type { ImportProgress, ExportProgress } from '../../../src/types/base' import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../shared/types' -import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../../src/types/format' - import type { TimeFilter } from '@openchatlab/shared-types' // 迁移相关类型 @@ -75,10 +73,6 @@ interface ChatApi { sessionId: string, filePath: string ) => Promise<{ success: boolean; newMessageCount: number; error?: string }> - exportSessionsToTempFiles: ( - sessionIds: string[] - ) => Promise<{ success: boolean; tempFiles: string[]; error?: string }> - cleanupTempExportFiles: (filePaths: string[]) => Promise<{ success: boolean; error?: string }> importDemo: (locale: string) => Promise<{ success: boolean groupSessionId?: string @@ -114,13 +108,6 @@ interface Api { } } -interface MergeApi { - parseFileInfo: (filePath: string) => Promise - checkConflicts: (filePaths: string[]) => Promise - mergeFiles: (params: MergeParams) => Promise - clearCache: (filePath?: string) => Promise -} - interface FilterMessage { id: number senderName: string @@ -758,7 +745,6 @@ declare global { electron: ElectronAPI api: Api chatApi: ChatApi - mergeApi: MergeApi aiApi: AiApi llmApi: LlmApi agentApi: AgentApi @@ -782,7 +768,6 @@ interface InternalApi { export { ChatApi, Api, - MergeApi, AiApi, LlmApi, ProviderDefinition, diff --git a/apps/desktop/preload/index.ts b/apps/desktop/preload/index.ts index 2def2d07..bf8bd428 100644 --- a/apps/desktop/preload/index.ts +++ b/apps/desktop/preload/index.ts @@ -7,7 +7,7 @@ import { electronAPI } from '@electron-toolkit/preload' // 从拆分的模块导入 API import { extendedApi } from './apis/core' -import { chatApi, mergeApi } from './apis/chat' +import { chatApi } from './apis/chat' import { aiApi, llmApi, agentApi } from './apis/ai' import { networkApi, cacheApi, sessionApi } from './apis/utils' import { apiServerApi } from './apis/api-server' @@ -36,7 +36,6 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', extendedApi) contextBridge.exposeInMainWorld('chatApi', chatApi) - contextBridge.exposeInMainWorld('mergeApi', mergeApi) contextBridge.exposeInMainWorld('aiApi', aiApi) contextBridge.exposeInMainWorld('llmApi', llmApi) contextBridge.exposeInMainWorld('agentApi', agentApi) @@ -56,8 +55,6 @@ if (process.contextIsolated) { // @ts-ignore (define in dts) window.chatApi = chatApi // @ts-ignore (define in dts) - window.mergeApi = mergeApi - // @ts-ignore (define in dts) window.aiApi = aiApi // @ts-ignore (define in dts) window.llmApi = llmApi diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts index f49dd33f..51858a28 100644 --- a/packages/http-routes/src/context.ts +++ b/packages/http-routes/src/context.ts @@ -17,6 +17,7 @@ import type { LLMConfigStore, CustomProviderStore, CustomModelStore, + MergeSessionCache, } from '@openchatlab/node-runtime' export interface HttpRouteContext { @@ -26,11 +27,19 @@ export interface HttpRouteContext { getVersion: () => string - /** Reserved for future migrated routes (import/merge) that need the native binding path */ + /** Native binding path for better-sqlite3 (CLI needs it, Electron does not) */ nativeBinding?: string preferencesManager?: PreferencesManager + /** Merge subsystem — optional, merge routes gracefully skip when absent */ + mergeSessionCache?: MergeSessionCache + /** + * Platform-specific import function for merge "andImport" flow. + * CLI and Electron each provide their own implementation. + */ + streamImport?: (dbManager: DatabaseManager, filePath: string) => Promise<{ sessionId: string }> + /** AI subsystem — optional, routes gracefully skip when absent */ aiDataDir?: string conversationManager?: AIConversationManager diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts index c7b8c3f1..4ac7bd84 100644 --- a/packages/http-routes/src/register.ts +++ b/packages/http-routes/src/register.ts @@ -22,6 +22,7 @@ import { registerAiSkillRoutes } from './routes/web/ai-skills' import { registerAiLlmRoutes } from './routes/web/ai-llm' import { registerAiConversationRoutes } from './routes/web/ai-conversations' import { registerAiSummaryRoutes } from './routes/web/ai-summaries' +import { registerMergeRoutes } from './routes/web/merge' export interface SharedRouteOptions { /** When true, AI routes will throw on missing dependencies instead of silently skipping */ @@ -66,4 +67,7 @@ export function registerSharedRoutes( registerAiLlmRoutes(server, ctx) registerAiConversationRoutes(server, ctx) registerAiSummaryRoutes(server, ctx) + + // Merge routes (graceful skip when mergeSessionCache is absent) + registerMergeRoutes(server, ctx) } diff --git a/apps/cli/src/http/routes/web/merge.ts b/packages/http-routes/src/routes/web/merge.ts similarity index 59% rename from apps/cli/src/http/routes/web/merge.ts rename to packages/http-routes/src/routes/web/merge.ts index 83d47c29..b9b5bedf 100644 --- a/apps/cli/src/http/routes/web/merge.ts +++ b/packages/http-routes/src/routes/web/merge.ts @@ -1,8 +1,16 @@ +/** + * Merge routes — shared across CLI, Electron, and web-serve. + * + * Parse supports dual-mode: + * - multipart/form-data → file upload (web/CLI) + * - application/json { filePath } → local disk path (Electron) + */ + import * as fs from 'fs' import * as os from 'os' import * as path from 'path' import type { FastifyInstance } from 'fastify' -import type { DatabaseManager } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' import { streamParseFileInfo, checkConflictsFromSources, @@ -10,39 +18,62 @@ import { serializeChatLabToJsonl, exportSessionToJson, TempDbReader, + TempDbWriter, } from '@openchatlab/node-runtime' -import { MergeSessionCache } from '../../../merger/merge-cache' -import { ensureDb } from './helpers' +import type { MergerDataSource } from '@openchatlab/node-runtime' +import { sessionNotFound } from '../../errors' + +function ensureDb(ctx: HttpRouteContext, sessionId: string) { + const db = ctx.dbManager.open(sessionId) + if (!db) throw sessionNotFound(sessionId) + return db +} + +export function registerMergeRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { dbManager, mergeSessionCache: mergeCache, streamImport } = ctx + if (!mergeCache) return + + // ── parse (dual-mode) ────────────────────────────────────────────── -export function registerMergeRoutes( - server: FastifyInstance, - dbManager: DatabaseManager, - mergeCache: MergeSessionCache -): void { server.post('/_web/merge/parse', async (request, reply) => { - const data = await (request as any).file() - if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + const contentType = request.headers['content-type'] || '' + let filePath: string + let cleanupFile = false + let cleanupDir: string | undefined - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-merge-')) - const tmpPath = path.join(tmpDir, data.filename || 'upload') + if (contentType.includes('multipart/form-data')) { + const data = await (request as any).file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) - const chunks: Buffer[] = [] - for await (const chunk of data.file) { - chunks.push(chunk) + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-merge-')) + const tmpPath = path.join(tmpDir, data.filename || 'upload') + const chunks: Buffer[] = [] + for await (const chunk of data.file) { + chunks.push(chunk) + } + fs.writeFileSync(tmpPath, Buffer.concat(chunks)) + filePath = tmpPath + cleanupFile = true + cleanupDir = tmpDir + } else { + const body = request.body as { filePath?: string } + if (!body?.filePath) return reply.code(400).send({ error: 'Missing filePath in body' }) + if (!fs.existsSync(body.filePath)) return reply.code(400).send({ error: 'File not found' }) + filePath = body.filePath } - fs.writeFileSync(tmpPath, Buffer.concat(chunks)) try { - const result = await streamParseFileInfo(tmpPath, { + const result = await streamParseFileInfo(filePath, { createTempDatabase(sourceFilePath: string) { return mergeCache.createTempDatabase(path.basename(sourceFilePath)) }, onProgress() { - /* no-op for HTTP merge parse */ + /* HTTP parse has no progress stream */ }, }) - const handle = mergeCache.store(data.filename || 'upload', result.tempDbPath) + const filename = path.basename(filePath) + const handle = mergeCache.store(filename, result.tempDbPath) return { handle, @@ -54,19 +85,25 @@ export function registerMergeRoutes( fileSize: result.fileSize, } } finally { - try { - fs.unlinkSync(tmpPath) - } catch { - /* ignore */ + if (cleanupFile) { + try { + fs.unlinkSync(filePath) + } catch { + /* ignore */ + } } - try { - fs.rmdirSync(tmpDir) - } catch { - /* ignore */ + if (cleanupDir) { + try { + fs.rmdirSync(cleanupDir) + } catch { + /* ignore */ + } } } }) + // ── conflicts ────────────────────────────────────────────────────── + server.post<{ Body: { handles: string[] } }>('/_web/merge/conflicts', async (request, reply) => { const { handles } = request.body as { handles?: string[] } if (!handles || !Array.isArray(handles) || handles.length === 0) { @@ -75,7 +112,7 @@ export function registerMergeRoutes( const readers: TempDbReader[] = [] try { - const dataSources: Array<{ source: import('@openchatlab/node-runtime').MergerDataSource; filename: string }> = [] + const dataSources: Array<{ source: MergerDataSource; filename: string }> = [] for (const handle of handles) { const entry = mergeCache.openReader(handle) if (!entry) return reply.code(404).send({ error: `Handle not found: ${handle}` }) @@ -88,6 +125,8 @@ export function registerMergeRoutes( } }) + // ── execute ──────────────────────────────────────────────────────── + server.post<{ Body: { handles: string[]; outputName: string; format?: 'json' | 'jsonl'; andImport?: boolean } }>('/_web/merge/execute', async (request, reply) => { @@ -101,7 +140,7 @@ export function registerMergeRoutes( const readers: TempDbReader[] = [] try { - const dataSources: Array<{ source: import('@openchatlab/node-runtime').MergerDataSource; filename: string }> = [] + const dataSources: Array<{ source: MergerDataSource; filename: string }> = [] for (const handle of handles) { const entry = mergeCache.openReader(handle) if (!entry) return reply.code(404).send({ error: `Handle not found: ${handle}` }) @@ -113,14 +152,17 @@ export function registerMergeRoutes( let sessionId: string | undefined if (andImport) { - const { streamImport: serverStreamImport } = await import('../../../import/stream-import') + if (!streamImport) { + return reply.code(501).send({ error: 'Import not available on this server' }) + } const jsonData = JSON.stringify(merged.chatLabData) const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-merged-')) - const tmpPath = path.join(tmpDir, `${outputName}.json`) + const safeName = path.basename(outputName).replace(/[/\\?%*:|"<>]/g, '_') || 'merged' + const tmpPath = path.join(tmpDir, `${safeName}.json`) fs.writeFileSync(tmpPath, jsonData, 'utf-8') try { - const importResult = await serverStreamImport(dbManager, tmpPath) + const importResult = await streamImport(dbManager, tmpPath) sessionId = importResult.sessionId } finally { try { @@ -154,6 +196,8 @@ export function registerMergeRoutes( } }) + // ── clear ────────────────────────────────────────────────────────── + server.post<{ Body: { handle?: string } }>('/_web/merge/clear', async (request) => { const { handle } = (request.body as any) || {} if (handle) { @@ -164,6 +208,8 @@ export function registerMergeRoutes( return { success: true } }) + // ── export sessions for merge ────────────────────────────────────── + server.post<{ Body: { sessionIds: string[] } }>('/_web/sessions/export-for-merge', async (request, reply) => { @@ -174,11 +220,10 @@ export function registerMergeRoutes( const handles: Array<{ sessionId: string; handle: string }> = [] for (const sid of sessionIds) { - const db = ensureDb(dbManager, sid) + const db = ensureDb(ctx, sid) const exported = exportSessionToJson(db) const { db: tempDb, tempDbPath } = mergeCache.createTempDatabase(exported.meta.name) - const { TempDbWriter } = await import('@openchatlab/node-runtime') const writer = new TempDbWriter(tempDb) writer.writeMeta({ name: exported.meta.name, diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 822d5fd1..cf8764b9 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -332,7 +332,7 @@ export * as memberService from './services/member-service' export * as sessionIndexService from './services/session-index-service' export * as summaryService from './services/summary-service' export * as exportService from './services/export-service' -export { createDatabaseManagerAdapter } from './services' +export { createDatabaseManagerAdapter, MergeSessionCache } from './services' export type { SessionRuntimeAdapter, AnalysisSessionDTO, diff --git a/packages/node-runtime/src/services/index.ts b/packages/node-runtime/src/services/index.ts index 7d56e9fb..30cb6365 100644 --- a/packages/node-runtime/src/services/index.ts +++ b/packages/node-runtime/src/services/index.ts @@ -41,3 +41,6 @@ export type { LlmConfig, SummaryServiceDeps } from './summary-service' // Export service export { exportMarkdown } from './export-service' + +// Merge cache +export { MergeSessionCache } from './merge-cache' diff --git a/packages/node-runtime/src/services/merge-cache.ts b/packages/node-runtime/src/services/merge-cache.ts new file mode 100644 index 00000000..da47992e --- /dev/null +++ b/packages/node-runtime/src/services/merge-cache.ts @@ -0,0 +1,99 @@ +/** + * Server-side merge session cache. + * + * Manages temporary databases for the multi-file merge workflow. + * Each uploaded/parsed file gets a temp DB handle that persists + * across HTTP requests until merge completes or cache is cleared. + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import type { PathProvider, DatabaseAdapter } from '@openchatlab/core' +import { openBetterSqliteDatabase } from '../better-sqlite3-adapter' +import { TempDbReader, deleteTempDatabase, cleanupTempDatabases } from '../merger/temp-db' + +interface CacheEntry { + tempDbPath: string + filename: string + createdAt: number +} + +export class MergeSessionCache { + private cache = new Map() + private tempDir: string + private nativeBinding?: string + + constructor(pathProvider: PathProvider, options?: { nativeBinding?: string }) { + this.tempDir = pathProvider.getTempDir() + this.nativeBinding = options?.nativeBinding + if (!fs.existsSync(this.tempDir)) { + fs.mkdirSync(this.tempDir, { recursive: true }) + } + } + + generateTempDbPath(filename: string): string { + const ts = Date.now() + const rand = Math.random().toString(36).slice(2, 8) + const safeName = filename.replace(/[/\\?%*:|"<>]/g, '_').slice(0, 40) + return path.join(this.tempDir, `merge_${safeName}_${ts}_${rand}.db`) + } + + /** Store a parsed file's temp DB and return a handle for subsequent requests. */ + store(filename: string, tempDbPath: string): string { + const handle = crypto.randomUUID() + this.cache.set(handle, { tempDbPath, filename, createdAt: Date.now() }) + return handle + } + + /** Open a TempDbReader for a given handle. Caller must close the reader. */ + openReader(handle: string): { reader: TempDbReader; filename: string } | null { + const entry = this.cache.get(handle) + if (!entry || !fs.existsSync(entry.tempDbPath)) return null + const adapter = openBetterSqliteDatabase(entry.tempDbPath, { + readonly: true, + nativeBinding: this.nativeBinding, + }) + return { reader: new TempDbReader(adapter), filename: entry.filename } + } + + /** Create a writable temp DB for streaming parse. */ + createTempDatabase(filename: string): { db: DatabaseAdapter; tempDbPath: string } { + const tempDbPath = this.generateTempDbPath(filename) + const adapter = openBetterSqliteDatabase(tempDbPath, { nativeBinding: this.nativeBinding }) + return { db: adapter, tempDbPath } + } + + /** Remove a single entry and its temp DB file. */ + delete(handle: string): void { + const entry = this.cache.get(handle) + if (entry) { + deleteTempDatabase(entry.tempDbPath) + this.cache.delete(handle) + } + } + + /** Clear all entries and delete their temp DB files. */ + clear(): void { + for (const entry of this.cache.values()) { + deleteTempDatabase(entry.tempDbPath) + } + this.cache.clear() + } + + /** Cleanup orphan temp DBs from previous runs. */ + cleanupOrphans(): void { + cleanupTempDatabases(this.tempDir) + } + + /** Auto-cleanup entries older than maxAge (default 1 hour). */ + cleanupExpired(maxAgeMs = 3600_000): void { + const now = Date.now() + for (const [handle, entry] of this.cache) { + if (now - entry.createdAt > maxAgeMs) { + deleteTempDatabase(entry.tempDbPath) + this.cache.delete(handle) + } + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad70aba6..aec85e37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@35.7.5) + '@fastify/multipart': + specifier: ^10.0.0 + version: 10.0.0 '@node-rs/jieba': specifier: ^2.0.1 version: 2.0.1 @@ -2484,6 +2487,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@zumer/snapdom@2.0.2': resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==} @@ -3603,7 +3607,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -5201,6 +5205,7 @@ packages: tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} diff --git a/src/components/common/Settings/BatchManageTab.vue b/src/components/common/Settings/BatchManageTab.vue index 0c310998..d3c5cc6f 100644 --- a/src/components/common/Settings/BatchManageTab.vue +++ b/src/components/common/Settings/BatchManageTab.vue @@ -405,7 +405,7 @@ async function executeMerge() { try { // 1. 导出选中的会话为临时文件 - const exportResult = await window.chatApi.exportSessionsToTempFiles(selectedSessionIds) + const exportResult = await window.mergeApi.exportSessionsToTempFiles(selectedSessionIds) if (!exportResult.success) { throw new Error(exportResult.error || '导出失败') } @@ -456,7 +456,7 @@ async function executeMerge() { } // 6. 清理临时文件 - await window.chatApi.cleanupTempExportFiles(tempFiles) + await window.mergeApi.cleanupTempExportFiles(tempFiles) // 7. 刷新会话列表 await sessionStore.loadSessions() @@ -473,7 +473,7 @@ async function executeMerge() { // 清理临时文件 if (tempFiles.length > 0) { - await window.chatApi.cleanupTempExportFiles(tempFiles) + await window.mergeApi.cleanupTempExportFiles(tempFiles) } } finally { isMerging.value = false diff --git a/src/env.d.ts b/src/env.d.ts index b5fdda41..f3779405 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -9,3 +9,22 @@ declare module '*.vue' { const component: DefineComponent, Record, unknown> export default component } + +import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from './types/format' + +interface MergeApi { + exportSessionsToTempFiles: ( + sessionIds: string[] + ) => Promise<{ success: boolean; tempFiles: string[]; error?: string }> + cleanupTempExportFiles: (filePaths: string[]) => Promise<{ success: boolean; error?: string }> + parseFileInfo: (filePath: string) => Promise + checkConflicts: (filePaths: string[]) => Promise + mergeFiles: (params: MergeParams) => Promise + clearCache: (filePath?: string) => Promise +} + +declare global { + interface Window { + mergeApi: MergeApi + } +} diff --git a/src/services/registry.ts b/src/services/registry.ts index ca0f0b01..aa594642 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -94,6 +94,8 @@ async function initElectronAdapters(): Promise { const { FetchSkillAdapter } = await import('./skill/fetch') registerAdapter('skill-crud', new FetchSkillAdapter()) + + installMergeShims('electron') } async function initWebServeAdapters(): Promise { @@ -307,94 +309,7 @@ async function installAiApiShims(): Promise { ;(window as any).agentApi = agentApiImpl - // cacheApi shim (server-side file operations) - // chatApi — merge-related shims (handles ↔ filePaths adaptation) - if (!(window as any).chatApi) { - ;(window as any).chatApi = {} - } - const chatApi = (window as any).chatApi - chatApi.exportSessionsToTempFiles = async (sessionIds: string[]) => { - try { - const resp = await fetchWithAuth('/_web/sessions/export-for-merge', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionIds }), - }) - const result = await resp.json() - if (!result.success) return { success: false, tempFiles: [], error: result.error } - return { success: true, tempFiles: result.handles.map((h: { handle: string }) => h.handle) } - } catch (error) { - return { success: false, tempFiles: [], error: error instanceof Error ? error.message : String(error) } - } - } - chatApi.cleanupTempExportFiles = async (filePaths: string[]) => { - try { - for (const handle of filePaths) { - await fetchWithAuth('/_web/merge/clear', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ handle }), - }) - } - return { success: true } - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) } - } - } - - // mergeApi — delegates to CLI Web merge HTTP routes (handle-based) - ;(window as any).mergeApi = { - parseFileInfo: async (_filePath: string) => { - // In CLI Web, sessions are pre-parsed during export-for-merge - return { name: '', format: '', platform: '', messageCount: 0, memberCount: 0, fileSize: 0 } - }, - checkConflicts: async (filePaths: string[]) => { - try { - const resp = await fetchWithAuth('/_web/merge/conflicts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ handles: filePaths }), - }) - return await resp.json() - } catch { - return { conflicts: [], totalMessages: 0 } - } - }, - mergeFiles: async (params: { - filePaths: string[] - outputName: string - outputFormat?: string - andAnalyze?: boolean - }) => { - try { - const resp = await fetchWithAuth('/_web/merge/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - handles: params.filePaths, - outputName: params.outputName, - format: params.outputFormat || 'json', - andImport: params.andAnalyze ?? false, - }), - }) - return await resp.json() - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) } - } - }, - clearCache: async (filePath?: string) => { - try { - await fetchWithAuth('/_web/merge/clear', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ handle: filePath }), - }) - return true - } catch { - return false - } - }, - } + installMergeShims('web-serve') ;(window as any).cacheApi = { saveToDownloads: async (filename: string, dataUrl: string) => { try { @@ -471,6 +386,143 @@ async function installAiApiShims(): Promise { } } +/** + * Install merge-related window shims. + * + * Both Electron and web-serve use the same HTTP merge routes. The shim + * maintains a filePath→handle Map so that existing frontend code + * (session.ts, BatchManageTab.vue) can continue calling with filePaths + * while the HTTP layer operates with UUID handles. + */ +function installMergeShims(platform: 'electron' | 'web-serve'): void { + const pathToHandle = new Map() + + const isHandle = (v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) + + const resolveHandle = (filePathOrHandle: string) => pathToHandle.get(filePathOrHandle) ?? filePathOrHandle + + async function ensureOk(resp: Response, context: string): Promise { + if (!resp.ok) { + const body = await resp.text().catch(() => '') + throw new Error(`[mergeApi] ${context} failed (${resp.status}): ${body}`) + } + } + + ;(window as any).mergeApi = { + exportSessionsToTempFiles: async (sessionIds: string[]) => { + try { + const resp = await fetchWithAuth('/_web/sessions/export-for-merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionIds }), + }) + await ensureOk(resp, 'exportSessionsToTempFiles') + const result = await resp.json() + if (!result.success) return { success: false, tempFiles: [], error: result.error } + const tempFiles = result.handles.map((h: { handle: string }) => h.handle) + return { success: true, tempFiles } + } catch (error) { + return { success: false, tempFiles: [], error: error instanceof Error ? error.message : String(error) } + } + }, + + cleanupTempExportFiles: async (filePaths: string[]) => { + try { + for (const fp of filePaths) { + const handle = resolveHandle(fp) + await fetchWithAuth('/_web/merge/clear', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handle }), + }) + pathToHandle.delete(fp) + } + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }, + + parseFileInfo: async (filePath: string) => { + if (isHandle(filePath) || pathToHandle.has(filePath)) { + return { name: '', format: '', platform: '', messageCount: 0, memberCount: 0, fileSize: 0 } + } + + if (platform === 'electron') { + const resp = await fetchWithAuth('/_web/merge/parse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filePath }), + }) + await ensureOk(resp, 'parseFileInfo') + const result = await resp.json() + if (!result.handle) throw new Error('Parse succeeded but no handle returned') + pathToHandle.set(filePath, result.handle) + return result + } + + return { name: '', format: '', platform: '', messageCount: 0, memberCount: 0, fileSize: 0 } + }, + + checkConflicts: async (filePaths: string[]) => { + const handles = filePaths.map(resolveHandle) + const resp = await fetchWithAuth('/_web/merge/conflicts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handles }), + }) + await ensureOk(resp, 'checkConflicts') + return await resp.json() + }, + + mergeFiles: async (params: { + filePaths: string[] + outputName: string + outputFormat?: string + andAnalyze?: boolean + }) => { + try { + const handles = params.filePaths.map(resolveHandle) + const resp = await fetchWithAuth('/_web/merge/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + handles, + outputName: params.outputName, + format: params.outputFormat || 'json', + andImport: params.andAnalyze ?? false, + }), + }) + await ensureOk(resp, 'mergeFiles') + const result = await resp.json() + for (const fp of params.filePaths) pathToHandle.delete(fp) + return result + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }, + + clearCache: async (filePath?: string) => { + try { + const handle = filePath ? resolveHandle(filePath) : undefined + await fetchWithAuth('/_web/merge/clear', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handle }), + }) + if (filePath) { + pathToHandle.delete(filePath) + } else { + pathToHandle.clear() + } + return true + } catch { + return false + } + }, + } +} + async function initWebBrowserAdapters(): Promise { // Phase 6+: BrowserSql Adapter throw new Error('[services] web-browser platform not yet supported')