From f7a8d06fe10c9e1788725e6e4010fd15550cdc78 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Mon, 27 Oct 2025 17:25:25 +0800 Subject: [PATCH 01/27] feat(backup): Break change redesign data backup and import (#1052) * style(settings): format data import dialog markup * fix(sync): harden backup import handling * fix(sync): validate backup filename * fix(sync): refine backup workflow safety * feat(sync): toast manual backup and import * fix: settings window toast failed * fix: lint and review issue * fix: restore merge-based config --- src/main/events.ts | 1 + .../presenter/sqlitePresenter/importData.ts | 473 ++-------- src/main/presenter/syncPresenter/index.ts | 879 ++++++++++-------- src/main/presenter/windowPresenter/index.ts | 23 + src/renderer/settings/App.vue | 84 +- .../settings/components/DataSettings.vue | 139 ++- src/renderer/src/App.vue | 7 +- src/renderer/src/assets/style.css | 2 +- src/renderer/src/events.ts | 1 + src/renderer/src/i18n/en-US/settings.json | 16 +- src/renderer/src/i18n/fa-IR/settings.json | 16 +- src/renderer/src/i18n/fr-FR/settings.json | 16 +- src/renderer/src/i18n/ja-JP/settings.json | 16 +- src/renderer/src/i18n/ko-KR/settings.json | 16 +- src/renderer/src/i18n/pt-BR/settings.json | 16 +- src/renderer/src/i18n/ru-RU/settings.json | 16 +- src/renderer/src/i18n/zh-CN/settings.json | 16 +- src/renderer/src/i18n/zh-HK/settings.json | 16 +- src/renderer/src/i18n/zh-TW/settings.json | 16 +- src/renderer/src/stores/sync.ts | 55 +- .../types/presenters/legacy.presenters.d.ts | 10 +- test/main/presenter/SyncPresenter.test.ts | 369 ++++++++ 22 files changed, 1422 insertions(+), 781 deletions(-) create mode 100644 test/main/presenter/SyncPresenter.test.ts diff --git a/src/main/events.ts b/src/main/events.ts index 7e072d1bd..0aa5d288b 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -118,6 +118,7 @@ export const SYNC_EVENTS = { BACKUP_STARTED: 'sync:backup-started', BACKUP_COMPLETED: 'sync:backup-completed', BACKUP_ERROR: 'sync:backup-error', + BACKUP_STATUS_CHANGED: 'sync:backup-status-changed', IMPORT_STARTED: 'sync:import-started', IMPORT_COMPLETED: 'sync:import-completed', IMPORT_ERROR: 'sync:import-error', diff --git a/src/main/presenter/sqlitePresenter/importData.ts b/src/main/presenter/sqlitePresenter/importData.ts index 507786bc1..3e129699d 100644 --- a/src/main/presenter/sqlitePresenter/importData.ts +++ b/src/main/presenter/sqlitePresenter/importData.ts @@ -1,6 +1,13 @@ import Database from 'better-sqlite3-multiple-ciphers' -import { nanoid } from 'nanoid' -import path from 'path' + +export interface ImportSummary { + tableCounts: Record +} + +type ColumnInfo = { + name: string + pk: number +} /** * 数据导入类 @@ -9,438 +16,160 @@ import path from 'path' export class DataImporter { private sourceDb: Database.Database private targetDb: Database.Database - private idMappings: { - conversations: Map - messages: Map - attachments: Map - } - /** - * 构造函数 - * @param sourcePath 源数据库路径 - * @param targetDbOrPath 目标数据库实例或路径 - * @param sourcePassword 源数据库密码(如果有) - * @param targetPassword 目标数据库密码(如果有) - */ constructor( sourcePath: string, targetDbOrPath: Database.Database | string, sourcePassword?: string, targetPassword?: string ) { - // 初始化源数据库连接 this.sourceDb = new Database(sourcePath) this.sourceDb.pragma('journal_mode = WAL') - // 如果有密码,设置加密 if (sourcePassword) { - this.sourceDb.pragma(`cipher='sqlcipher'`) - this.sourceDb.pragma(`key='${sourcePassword}'`) + this.sourceDb.pragma("cipher='sqlcipher'") + const hex = Buffer.from(sourcePassword, 'utf8').toString('hex') + this.sourceDb.pragma(`key = "x'${hex}'"`) } - // 设置目标数据库 if (typeof targetDbOrPath === 'string') { - // 如果传入的是路径字符串,创建新的数据库连接 this.targetDb = new Database(targetDbOrPath) this.targetDb.pragma('journal_mode = WAL') - // 如果有目标数据库密码,设置加密 if (targetPassword) { - this.targetDb.pragma(`cipher='sqlcipher'`) - this.targetDb.pragma(`key='${targetPassword}'`) + this.targetDb.pragma("cipher='sqlcipher'") + const hex = Buffer.from(targetPassword, 'utf8').toString('hex') + this.targetDb.pragma(`key = "x'${hex}'"`) } } else { - // 如果传入的是数据库实例,直接使用 this.targetDb = targetDbOrPath } - - // 初始化ID映射 - this.idMappings = { - conversations: new Map(), - messages: new Map(), - attachments: new Map() - } } /** * 开始导入数据 - * @returns 导入的会话数量 */ - public async importData(): Promise { - // 获取所有会话 - 兼容不同版本的数据库schema - let conversations: any[] - - try { - // 尝试使用包含所有新字段的查询 - conversations = this.sourceDb - .prepare( - `SELECT - conv_id, title, created_at, updated_at, system_prompt, - temperature, context_length, max_tokens, provider_id, - model_id, - COALESCE(is_pinned, 0) as is_pinned, - COALESCE(is_new, 0) as is_new, - COALESCE(artifacts, 0) as artifacts, - enabled_mcp_tools, - thinking_budget, - reasoning_effort, - verbosity, - enable_search, - forced_search, - search_strategy - FROM conversations` - ) - .all() as any[] - } catch { - // 如果失败,使用基础字段查询(兼容旧版本数据库) - try { - conversations = this.sourceDb - .prepare( - `SELECT - conv_id, title, created_at, updated_at, system_prompt, - temperature, context_length, max_tokens, provider_id, - model_id, - COALESCE(is_pinned, 0) as is_pinned, - COALESCE(is_new, 0) as is_new, - COALESCE(artifacts, 0) as artifacts - FROM conversations` - ) - .all() as any[] - - // 为缺失的字段设置默认值 - conversations = conversations.map((conv) => ({ - ...conv, - enabled_mcp_tools: null, - thinking_budget: null, - reasoning_effort: null, - verbosity: null, - enable_search: null, - forced_search: null, - search_strategy: null - })) - } catch (fallbackError) { - throw new Error( - `Failed to query conversations: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}` - ) - } - } + public async importData(): Promise { + const tableCounts: Record = {} + const tables = this.getTablesInOrder() - // 使用better-sqlite3的transaction API来处理事务 const importTransaction = this.targetDb.transaction(() => { - let importedCount = 0 - for (const conv of conversations) { - // 如果是增量导入模式,检查会话是否已存在 - const existingConv = this.targetDb - .prepare('SELECT conv_id FROM conversations WHERE conv_id = ?') - .get(conv.conv_id) - if (existingConv) { - continue // 跳过已存在的会话 + for (const table of tables) { + try { + const inserted = this.importTable(table) + if (inserted > 0) { + tableCounts[table] = inserted + } + } catch (error) { + throw new Error( + `Failed to import table ${table}: ${error instanceof Error ? error.message : String(error)}` + ) } - - this.importConversation(conv) - importedCount++ } - return importedCount }) try { - // 执行事务并返回导入的会话数量 - return importTransaction() + importTransaction() + return { tableCounts } } catch (transactionError) { - // 事务会自动回滚,抛出详细错误 throw new Error( - `Failed to import data: ${transactionError instanceof Error ? transactionError.message : String(transactionError)}` + `Failed to import database: ${ + transactionError instanceof Error ? transactionError.message : String(transactionError) + }` ) } } - /** - * 导入单个会话及其相关数据 - * @param conv 会话数据 - */ - private importConversation(conv: any): void { - // 为会话生成新ID - // const newConvId = nanoid() - // this.idMappings.conversations.set(conv.conv_id, newConvId) + private getTablesInOrder(): string[] { + const tables = this.sourceDb + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + .all() as { name: string }[] - try { - // 首先尝试使用包含所有新字段的INSERT语句 - this.targetDb - .prepare( - `INSERT INTO conversations ( - conv_id, title, created_at, updated_at, system_prompt, - temperature, context_length, max_tokens, provider_id, - model_id, is_pinned, is_new, artifacts, enabled_mcp_tools, - thinking_budget, reasoning_effort, verbosity, - enable_search, forced_search, search_strategy - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - conv.conv_id, - conv.title, - conv.created_at, - conv.updated_at, - conv.system_prompt, - conv.temperature, - conv.context_length, - conv.max_tokens, - conv.provider_id, - conv.model_id, - conv.is_pinned || 0, - conv.is_new || 0, - conv.artifacts || 0, - conv.enabled_mcp_tools || null, - conv.thinking_budget || null, - conv.reasoning_effort || null, - conv.verbosity || null, - conv.enable_search ?? null, - conv.forced_search ?? null, - conv.search_strategy ?? null - ) - } catch { - // 如果失败,使用基础字段的INSERT语句(兼容旧版本目标数据库) - try { - this.targetDb - .prepare( - `INSERT INTO conversations ( - conv_id, title, created_at, updated_at, system_prompt, - temperature, context_length, max_tokens, provider_id, - model_id, is_pinned, is_new, artifacts - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - conv.conv_id, - conv.title, - conv.created_at, - conv.updated_at, - conv.system_prompt, - conv.temperature, - conv.context_length, - conv.max_tokens, - conv.provider_id, - conv.model_id, - conv.is_pinned || 0, - conv.is_new || 0, - conv.artifacts || 0 - ) - } catch (fallbackError) { - throw new Error( - `Failed to insert conversation ${conv.conv_id}: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}` - ) + const preferredOrder = ['conversations', 'messages', 'attachments', 'message_attachments'] + const preferredSet = new Set(preferredOrder) + + const preferredTables: string[] = [] + const remainingTables: string[] = [] + + for (const { name } of tables) { + if (preferredSet.has(name)) { + preferredTables.push(name) + } else { + remainingTables.push(name) } } - // 导入该会话的所有消息 - try { - this.importMessages(conv.conv_id) - } catch (messageError) { - throw new Error( - `Failed to import messages for conversation ${conv.conv_id}: ${messageError instanceof Error ? messageError.message : String(messageError)}` - ) - } - } + preferredTables.sort((a, b) => preferredOrder.indexOf(a) - preferredOrder.indexOf(b)) + remainingTables.sort() - /** - * 导入会话的所有消息 - * @param oldConvId 原会话ID - */ - private importMessages(oldConvId: string): void { - // 获取会话的所有消息 - const messages = this.sourceDb - .prepare( - `SELECT - msg_id, parent_id, role, content, created_at, - order_seq, token_count, status, metadata, - is_context_edge, is_variant - FROM messages - WHERE conversation_id = ? - ORDER BY order_seq` - ) - .all(oldConvId) as any[] + return [...preferredTables, ...remainingTables] + } - // 逐个导入消息 - for (const msg of messages) { - const newMsgId = nanoid() - this.idMappings.messages.set(msg.msg_id, newMsgId) + private importTable(tableName: string): number { + const sourceColumns = this.getTableColumns(this.sourceDb, tableName) + const targetColumns = this.getTableColumns(this.targetDb, tableName) - // 处理父消息ID映射 - let newParentId = '' - if (msg.parent_id && msg.parent_id !== '') { - newParentId = this.idMappings.messages.get(msg.parent_id) || '' - } + if (targetColumns.length === 0) { + return 0 + } - try { - // 插入消息 - this.targetDb - .prepare( - `INSERT INTO messages ( - msg_id, conversation_id, parent_id, role, content, - created_at, order_seq, token_count, status, metadata, - is_context_edge, is_variant - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - newMsgId, - oldConvId, - newParentId, - msg.role, - msg.content, - msg.created_at, - msg.order_seq, - msg.token_count || 0, - msg.status || 'sent', - msg.metadata || null, - msg.is_context_edge || 0, - msg.is_variant || 0 - ) + const targetColumnNames = new Set(targetColumns.map((column) => column.name)) + const commonColumns = sourceColumns.filter((column) => targetColumnNames.has(column.name)) - // 导入消息的附件 - this.importAttachments(msg.msg_id, newMsgId) - this.importMessageAttachments(msg.msg_id, newMsgId) - } catch (msgError) { - throw new Error( - `Failed to insert message ${msg.msg_id}: ${msgError instanceof Error ? msgError.message : String(msgError)}` - ) - } + if (commonColumns.length === 0) { + return 0 } - } - /** - * 导入消息的附件 - * @param oldMsgId 原消息ID - * @param newMsgId 新消息ID - */ - private importAttachments(oldMsgId: string, newMsgId: string): void { - // 获取消息的所有附件 - const attachments = this.sourceDb - .prepare( - `SELECT - attach_id, attachment_type, file_name, file_size, - storage_type, storage_path, thumbnail, vectorized, - data_summary, mime_type, created_at - FROM attachments - WHERE message_id = ?` - ) - .all(oldMsgId) as any[] + const pkColumns = targetColumns + .filter((column) => column.pk > 0 && commonColumns.some((col) => col.name === column.name)) + .sort((a, b) => a.pk - b.pk) - // 逐个导入附件 - for (const attachment of attachments) { - const newAttachId = nanoid() - this.idMappings.attachments.set(attachment.attach_id, newAttachId) + const wrappedTableName = this.wrapIdentifier(tableName) + const selectColumnsSql = commonColumns + .map((column) => this.wrapIdentifier(column.name)) + .join(', ') + const rows = this.sourceDb + .prepare(`SELECT ${selectColumnsSql} FROM ${wrappedTableName}`) + .all() as Record[] - // 处理存储路径 - let storagePath = attachment.storage_path - if (storagePath && attachment.storage_type === 'path') { - // 如果是文件路径,可能需要复制文件或调整路径 - // 这里简单处理,实际应用中可能需要更复杂的逻辑 - storagePath = path.basename(storagePath) - } + if (rows.length === 0) { + return 0 + } - // 插入附件 - this.targetDb - .prepare( - `INSERT INTO attachments ( - attach_id, message_id, attachment_type, file_name, - file_size, storage_type, storage_path, thumbnail, - vectorized, data_summary, mime_type, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - newAttachId, - newMsgId, - attachment.attachment_type, - attachment.file_name, - attachment.file_size || 0, - attachment.storage_type, - storagePath, - attachment.thumbnail, - attachment.vectorized || 0, - attachment.data_summary, - attachment.mime_type, - attachment.created_at - ) + const insertPlaceholders = Array.from({ length: commonColumns.length }, () => '?').join(', ') + const insertSql = + pkColumns.length > 0 + ? `INSERT OR IGNORE INTO ${wrappedTableName} (${selectColumnsSql}) VALUES (${insertPlaceholders})` + : `INSERT INTO ${wrappedTableName} (${selectColumnsSql}) VALUES (${insertPlaceholders})` + const insertStmt = this.targetDb.prepare(insertSql) + + let inserted = 0 + for (const row of rows) { + const values = commonColumns.map((column) => row[column.name]) + const info = insertStmt.run(...values) + if (pkColumns.length === 0 || info.changes > 0) { + inserted++ + } } - } - /** - * 导入消息的附件(message_attachments表) - * @param oldMsgId 原消息ID - * @param newMsgId 新消息ID - */ - private importMessageAttachments(oldMsgId: string, newMsgId: string): void { - // 获取消息的所有message_attachments - 兼容不同的schema版本 - let messageAttachments: any[] + return inserted + } + private getTableColumns(db: Database.Database, tableName: string): ColumnInfo[] { + const wrappedTableName = this.wrapIdentifier(tableName) try { - // 首先尝试包含metadata字段的查询 - messageAttachments = this.sourceDb - .prepare( - `SELECT - attachment_id, type, content, created_at, metadata - FROM message_attachments - WHERE message_id = ?` - ) - .all(oldMsgId) as any[] - } catch { - // 如果失败,使用不包含metadata的查询(兼容新版本schema) - messageAttachments = this.sourceDb - .prepare( - `SELECT - attachment_id, type, content, created_at - FROM message_attachments - WHERE message_id = ?` - ) - .all(oldMsgId) as any[] - - // 为缺失的字段设置默认值 - messageAttachments = messageAttachments.map((attachment) => ({ - ...attachment, - metadata: null - })) + const columns = db.prepare(`PRAGMA table_info(${wrappedTableName})`).all() as ColumnInfo[] + return columns + } catch (error) { + console.warn(`Failed to read table info for ${tableName}:`, error) + return [] } + } - // 逐个导入message_attachments - for (const attachment of messageAttachments) { - const newAttachmentId = nanoid() - - try { - // 首先尝试包含metadata字段的INSERT - this.targetDb - .prepare( - `INSERT INTO message_attachments ( - attachment_id, message_id, type, content, created_at, metadata - ) VALUES (?, ?, ?, ?, ?, ?)` - ) - .run( - newAttachmentId, - newMsgId, - attachment.type, - attachment.content, - attachment.created_at, - attachment.metadata - ) - } catch { - // 如果失败,使用不包含metadata的INSERT(兼容新版本schema) - this.targetDb - .prepare( - `INSERT INTO message_attachments ( - attachment_id, message_id, type, content, created_at - ) VALUES (?, ?, ?, ?, ?)` - ) - .run( - newAttachmentId, - newMsgId, - attachment.type, - attachment.content, - attachment.created_at - ) - } - } + private wrapIdentifier(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"` } - /** - * 关闭数据库连接 - */ public close(): void { if (this.sourceDb) { this.sourceDb.close() diff --git a/src/main/presenter/syncPresenter/index.ts b/src/main/presenter/syncPresenter/index.ts index 208fd9826..2227664f8 100644 --- a/src/main/presenter/syncPresenter/index.ts +++ b/src/main/presenter/syncPresenter/index.ts @@ -2,31 +2,56 @@ import { app, shell } from 'electron' import path from 'path' import fs from 'fs' import Database from 'better-sqlite3-multiple-ciphers' -import { ISyncPresenter, IConfigPresenter, ISQLitePresenter } from '@shared/presenter' +import { zipSync, unzipSync } from 'fflate' +import { + ISyncPresenter, + IConfigPresenter, + ISQLitePresenter, + SyncBackupInfo, + MCPServerConfig +} from '@shared/presenter' import { eventBus, SendTarget } from '@/eventbus' import { SYNC_EVENTS } from '@/events' import { DataImporter } from '../sqlitePresenter/importData' -import { ImportMode } from '../sqlitePresenter/index' +import { ImportMode } from '../sqlitePresenter' -// 为配置文件定义接口 -interface AppSettings { - syncEnabled?: boolean - syncFolderPath?: string - lastSyncTime?: number +interface PromptStore { + prompts: Array<{ id?: string; [key: string]: unknown }> +} + +interface McpSettings { + mcpServers?: Record + defaultServers?: string[] [key: string]: unknown } +type BackupStatus = 'idle' | 'preparing' | 'collecting' | 'compressing' | 'finalizing' | 'error' + +const BACKUP_PREFIX = 'backup-' +const BACKUP_EXTENSION = '.zip' +const BACKUP_FILE_NAME_REGEX = /^backup-\d+\.zip$/ + +const ZIP_PATHS = { + db: 'database/chat.db', + appSettings: 'configs/app-settings.json', + customPrompts: 'configs/custom_prompts.json', + systemPrompts: 'configs/system_prompts.json', + mcpSettings: 'configs/mcp-settings.json', + manifest: 'manifest.json' +} + export class SyncPresenter implements ISyncPresenter { private configPresenter: IConfigPresenter private sqlitePresenter: ISQLitePresenter - private isBackingUp: boolean = false + private isBackingUp = false + private currentBackupStatus: BackupStatus = 'idle' private backupTimer: NodeJS.Timeout | null = null - private readonly BACKUP_DELAY = 60 * 1000 // 60秒无变更后触发备份 + private readonly BACKUP_DELAY = 60 * 1000 private readonly APP_SETTINGS_PATH = path.join(app.getPath('userData'), 'app-settings.json') + private readonly CUSTOM_PROMPTS_PATH = path.join(app.getPath('userData'), 'custom_prompts.json') + private readonly SYSTEM_PROMPTS_PATH = path.join(app.getPath('userData'), 'system_prompts.json') private readonly MCP_SETTINGS_PATH = path.join(app.getPath('userData'), 'mcp-settings.json') - private readonly PROVIDER_MODELS_DIR_PATH = path.join(app.getPath('userData'), 'provider_models') private readonly DB_PATH = path.join(app.getPath('userData'), 'app_db', 'chat.db') - private readonly MODEL_CONFIG_PATH = path.join(app.getPath('userData'), 'model-config.json') constructor(configPresenter: IConfigPresenter, sqlitePresenter: ISQLitePresenter) { this.configPresenter = configPresenter @@ -35,68 +60,71 @@ export class SyncPresenter implements ISyncPresenter { } public init(): void { - // 监听数据变更事件,触发备份计划 this.listenForChanges() } public destroy(): void { - // 清理定时器 if (this.backupTimer) { clearTimeout(this.backupTimer) this.backupTimer = null } } - /** - * 检查同步文件夹状态 - */ public async checkSyncFolder(): Promise<{ exists: boolean; path: string }> { const syncFolderPath = this.configPresenter.getSyncFolderPath() const exists = fs.existsSync(syncFolderPath) - return { exists, path: syncFolderPath } } - /** - * 打开同步文件夹 - */ public async openSyncFolder(): Promise { const { exists, path: syncFolderPath } = await this.checkSyncFolder() - - // 如果文件夹不存在,先创建它 if (!exists) { fs.mkdirSync(syncFolderPath, { recursive: true }) } - - // 打开文件夹 shell.openPath(syncFolderPath) } - /** - * 获取备份状态 - */ public async getBackupStatus(): Promise<{ isBackingUp: boolean; lastBackupTime: number }> { const lastBackupTime = this.configPresenter.getLastSyncTime() return { isBackingUp: this.isBackingUp, lastBackupTime } } - /** - * 手动触发备份 - */ - public async startBackup(): Promise { + public async listBackups(): Promise { + const { path: syncFolderPath } = await this.checkSyncFolder() + const backupsDir = this.getBackupsDirectory(syncFolderPath) + if (!fs.existsSync(backupsDir)) { + return [] + } + + const entries = fs + .readdirSync(backupsDir) + .filter((file) => file.endsWith(BACKUP_EXTENSION)) + .map((fileName) => { + const match = fileName.match(/backup-(\d+)\.zip$/) + const createdAt = match + ? Number(match[1]) + : fs.statSync(path.join(backupsDir, fileName)).mtimeMs + const stats = fs.statSync(path.join(backupsDir, fileName)) + return { fileName, createdAt, size: stats.size } + }) + .sort((a, b) => b.createdAt - a.createdAt) + + return entries + } + + public async startBackup(): Promise { if (this.isBackingUp) { - return + return null } - // 检查同步功能是否启用 if (!this.configPresenter.getSyncEnabled()) { throw new Error('sync.error.notEnabled') } try { - await this.performBackup() - } catch (error: unknown) { - console.error('备份失败:', error) + return await this.performBackup() + } catch (error) { + console.error('Backup failed:', error) eventBus.send( SYNC_EVENTS.BACKUP_ERROR, SendTarget.ALL_WINDOWS, @@ -106,9 +134,6 @@ export class SyncPresenter implements ISyncPresenter { } } - /** - * 取消备份操作 - */ public async cancelBackup(): Promise { if (this.backupTimer) { clearTimeout(this.backupTimer) @@ -117,443 +142,567 @@ export class SyncPresenter implements ISyncPresenter { this.isBackingUp = false } - /** - * 从同步文件夹导入数据 - */ public async importFromSync( + backupFileName: string, importMode: ImportMode = ImportMode.INCREMENT ): Promise<{ success: boolean; message: string; count?: number }> { - // Cancel any pending backup to prevent overwriting the backup files during import if (this.backupTimer) { clearTimeout(this.backupTimer) this.backupTimer = null } - // 检查同步文件夹是否存在 const { exists, path: syncFolderPath } = await this.checkSyncFolder() if (!exists) { return { success: false, message: 'sync.error.folderNotExists' } } - // 检查是否有备份文件 - const dbBackupPath = path.join(syncFolderPath, 'chat.db') - const appSettingsBackupPath = path.join(syncFolderPath, 'app-settings.json') - const providerModelsBackupPath = path.join(syncFolderPath, 'provider_models') - const modelConfigBackupPath = path.join(syncFolderPath, 'model-config.json') - - if (!fs.existsSync(dbBackupPath) || !fs.existsSync(appSettingsBackupPath)) { + const backupsDir = this.getBackupsDirectory(syncFolderPath) + let backupZipPath: string + try { + const safeFileName = this.ensureSafeBackupFileName(backupFileName) + backupZipPath = path.join(backupsDir, safeFileName) + } catch (error) { + console.warn('Failed to validate backup file name', error) + return { success: false, message: 'sync.error.noValidBackup' } + } + if (!fs.existsSync(backupZipPath)) { return { success: false, message: 'sync.error.noValidBackup' } } - // 发出导入开始事件 eventBus.send(SYNC_EVENTS.IMPORT_STARTED, SendTarget.ALL_WINDOWS) - try { - // 关闭数据库连接 - this.sqlitePresenter.close() + const extractionDir = path.join(app.getPath('temp'), `deepchat-backup-${Date.now()}`) + fs.mkdirSync(extractionDir, { recursive: true }) - // 备份当前文件 - const tempDbPath = path.join(app.getPath('temp'), `chat_${Date.now()}.db`) - const tempAppSettingsPath = path.join(app.getPath('temp'), `app_settings_${Date.now()}.json`) - const tempProviderModelsPath = path.join(app.getPath('temp'), `provider_models_${Date.now()}`) - const tempMcpSettingsPath = path.join(app.getPath('temp'), `mcp_settings_${Date.now()}.json`) - const tempModelConfigPath = path.join(app.getPath('temp'), `model_config_${Date.now()}.json`) - // 创建临时备份 - if (fs.existsSync(this.DB_PATH)) { - fs.copyFileSync(this.DB_PATH, tempDbPath) - } - - if (fs.existsSync(this.APP_SETTINGS_PATH)) { - fs.copyFileSync(this.APP_SETTINGS_PATH, tempAppSettingsPath) - } + const tempCurrentFiles: Record = { + db: null, + appSettings: null, + customPrompts: null, + systemPrompts: null, + mcpSettings: null + } - if (fs.existsSync(this.MCP_SETTINGS_PATH)) { - fs.copyFileSync(this.MCP_SETTINGS_PATH, tempMcpSettingsPath) - } + try { + this.extractBackupArchive(backupZipPath, extractionDir) - // 备份模型配置文件 - if (fs.existsSync(this.MODEL_CONFIG_PATH)) { - fs.copyFileSync(this.MODEL_CONFIG_PATH, tempModelConfigPath) - } + const backupDbPath = path.join(extractionDir, ZIP_PATHS.db) + const backupAppSettingsPath = path.join(extractionDir, ZIP_PATHS.appSettings) + const backupCustomPromptsPath = path.join(extractionDir, ZIP_PATHS.customPrompts) + const backupSystemPromptsPath = path.join(extractionDir, ZIP_PATHS.systemPrompts) + const backupMcpSettingsPath = path.join(extractionDir, ZIP_PATHS.mcpSettings) - // 如果 provider_models 目录存在,备份整个目录 - if (fs.existsSync(this.PROVIDER_MODELS_DIR_PATH)) { - this.copyDirectory(this.PROVIDER_MODELS_DIR_PATH, tempProviderModelsPath) + if (!fs.existsSync(backupDbPath) || !fs.existsSync(backupAppSettingsPath)) { + throw new Error('sync.error.noValidBackup') } - let importedCount = 0 - try { - if (importMode === ImportMode.OVERWRITE) { - // For overwrite mode, count conversations from backup db in read-only mode - const backupDb = new Database(dbBackupPath, { readonly: true }) - const result = backupDb.prepare('SELECT COUNT(*) as count FROM conversations').get() as { - count: number - } - importedCount = result.count - backupDb.close() - - fs.copyFileSync(dbBackupPath, this.DB_PATH) - } else { - // For incremental mode, DataImporter returns the actual imported count - const importer = new DataImporter(dbBackupPath, this.DB_PATH) - importedCount = await importer.importData() - console.log(`成功导入 ${importedCount} 个会话`) - importer.close() - } - // 合并 app-settings.json 文件 (排除同步相关的设置) - if (fs.existsSync(appSettingsBackupPath)) { - // 读取当前的 app-settings - let currentSettings: AppSettings = {} - if (fs.existsSync(this.APP_SETTINGS_PATH)) { - const currentContent = fs.readFileSync(this.APP_SETTINGS_PATH, 'utf-8') - currentSettings = JSON.parse(currentContent) - } - - // 读取备份的 app-settings - const backupContent = fs.readFileSync(appSettingsBackupPath, 'utf-8') - const backupSettings = JSON.parse(backupContent) - - // 保留当前的同步相关设置 - const syncSettings: AppSettings = { - syncEnabled: currentSettings.syncEnabled, - syncFolderPath: currentSettings.syncFolderPath, - lastSyncTime: currentSettings.lastSyncTime - } - - // 合并设置: 使用备份的设置,但保留同步相关设置 - const mergedSettings = { ...backupSettings, ...syncSettings } - - // 保存合并后的设置 - fs.writeFileSync(this.APP_SETTINGS_PATH, JSON.stringify(mergedSettings, null, 2), 'utf-8') - } - - // 如果存在 provider_models 备份,复制整个目录(直接覆盖) - if (fs.existsSync(providerModelsBackupPath)) { - // 清空当前 provider_models 目录 - if (fs.existsSync(this.PROVIDER_MODELS_DIR_PATH)) { - this.removeDirectory(this.PROVIDER_MODELS_DIR_PATH) - } - // 确保目标目录存在 - fs.mkdirSync(this.PROVIDER_MODELS_DIR_PATH, { recursive: true }) - // 复制备份目录到应用目录 - this.copyDirectory(providerModelsBackupPath, this.PROVIDER_MODELS_DIR_PATH) - } - - // 导入模型配置文件 - if (fs.existsSync(modelConfigBackupPath)) { - fs.copyFileSync(modelConfigBackupPath, this.MODEL_CONFIG_PATH) - } + this.sqlitePresenter.close() - eventBus.send(SYNC_EVENTS.IMPORT_COMPLETED, SendTarget.ALL_WINDOWS) - return { success: true, message: 'sync.success.importComplete', count: importedCount } - } catch (error: unknown) { - console.error('导入文件失败,恢复备份:', error) + tempCurrentFiles.db = this.createTempBackup(this.DB_PATH, 'chat.db') + tempCurrentFiles.appSettings = this.createTempBackup( + this.APP_SETTINGS_PATH, + 'app-settings.json' + ) + tempCurrentFiles.customPrompts = this.createTempBackup( + this.CUSTOM_PROMPTS_PATH, + 'custom_prompts.json' + ) + tempCurrentFiles.systemPrompts = this.createTempBackup( + this.SYSTEM_PROMPTS_PATH, + 'system_prompts.json' + ) + tempCurrentFiles.mcpSettings = this.createTempBackup( + this.MCP_SETTINGS_PATH, + 'mcp-settings.json' + ) - // 恢复备份 - if (fs.existsSync(tempDbPath)) { - fs.copyFileSync(tempDbPath, this.DB_PATH) - } + let importedConversationCount = 0 - if (fs.existsSync(tempAppSettingsPath)) { - fs.copyFileSync(tempAppSettingsPath, this.APP_SETTINGS_PATH) + if (importMode === ImportMode.OVERWRITE) { + const backupDb = new Database(backupDbPath, { readonly: true }) + const result = backupDb.prepare('SELECT COUNT(*) as count FROM conversations').get() as { + count: number } + importedConversationCount = result?.count || 0 + backupDb.close() - if (fs.existsSync(tempMcpSettingsPath)) { - fs.copyFileSync(tempMcpSettingsPath, this.MCP_SETTINGS_PATH) - } + this.copyFile(backupDbPath, this.DB_PATH) + this.mergeAppSettingsPreservingSync(backupAppSettingsPath, this.APP_SETTINGS_PATH) - if (fs.existsSync(tempProviderModelsPath)) { - if (fs.existsSync(this.PROVIDER_MODELS_DIR_PATH)) { - this.removeDirectory(this.PROVIDER_MODELS_DIR_PATH) - } - this.copyDirectory(tempProviderModelsPath, this.PROVIDER_MODELS_DIR_PATH) + if (fs.existsSync(backupCustomPromptsPath)) { + this.copyFile(backupCustomPromptsPath, this.CUSTOM_PROMPTS_PATH) } - // 恢复模型配置文件 - if (fs.existsSync(tempModelConfigPath)) { - fs.copyFileSync(tempModelConfigPath, this.MODEL_CONFIG_PATH) + if (fs.existsSync(backupSystemPromptsPath)) { + this.copyFile(backupSystemPromptsPath, this.SYSTEM_PROMPTS_PATH) } - eventBus.send( - SYNC_EVENTS.IMPORT_ERROR, - SendTarget.ALL_WINDOWS, - (error as Error).message || 'sync.error.unknown' - ) - return { success: false, message: 'sync.error.importFailed' } - } finally { - // 清理临时文件 - if (fs.existsSync(tempDbPath)) { - fs.unlinkSync(tempDbPath) + if (fs.existsSync(backupMcpSettingsPath)) { + this.copyFile(backupMcpSettingsPath, this.MCP_SETTINGS_PATH) } - - if (fs.existsSync(tempAppSettingsPath)) { - fs.unlinkSync(tempAppSettingsPath) + } else { + const importer = new DataImporter(backupDbPath, this.DB_PATH) + const summary = await importer.importData() + importer.close() + importedConversationCount = summary.tableCounts.conversations || 0 + + this.mergeAppSettingsPreservingSync(backupAppSettingsPath, this.APP_SETTINGS_PATH) + if (fs.existsSync(backupCustomPromptsPath)) { + this.mergePromptStore(backupCustomPromptsPath, this.CUSTOM_PROMPTS_PATH) } - - if (fs.existsSync(tempMcpSettingsPath)) { - fs.unlinkSync(tempMcpSettingsPath) + if (fs.existsSync(backupSystemPromptsPath)) { + this.mergePromptStore(backupSystemPromptsPath, this.SYSTEM_PROMPTS_PATH) } - - if (fs.existsSync(tempProviderModelsPath)) { - this.removeDirectory(tempProviderModelsPath) + if (fs.existsSync(backupMcpSettingsPath)) { + this.mergeMcpSettings(backupMcpSettingsPath, this.MCP_SETTINGS_PATH) } + } - // 清理模型配置临时文件 - if (fs.existsSync(tempModelConfigPath)) { - fs.unlinkSync(tempModelConfigPath) - } + eventBus.send(SYNC_EVENTS.IMPORT_COMPLETED, SendTarget.ALL_WINDOWS) + return { + success: true, + message: 'sync.success.importComplete', + count: importedConversationCount } - } catch (error: unknown) { - console.error('导入过程出错:', error) + } catch (error) { + console.error('import failed,reverting:', error) + this.restoreFromTempBackup(tempCurrentFiles) eventBus.send( SYNC_EVENTS.IMPORT_ERROR, SendTarget.ALL_WINDOWS, (error as Error).message || 'sync.error.unknown' ) - return { success: false, message: 'sync.error.importProcess' } + return { success: false, message: 'sync.error.importFailed' } + } finally { + this.cleanupTempFiles(Object.values(tempCurrentFiles)) + this.removeDirectory(extractionDir) } } - /** - * 执行实际的备份操作 - */ - private async performBackup(): Promise { - // 标记备份开始 + private async performBackup(): Promise { this.isBackingUp = true + this.emitBackupStatus('preparing') eventBus.send(SYNC_EVENTS.BACKUP_STARTED, SendTarget.ALL_WINDOWS) - try { - const syncFolderPath = this.configPresenter.getSyncFolderPath() - - // 确保同步文件夹存在 - if (!fs.existsSync(syncFolderPath)) { - fs.mkdirSync(syncFolderPath, { recursive: true }) - } + const syncFolderPath = this.configPresenter.getSyncFolderPath() + if (!fs.existsSync(syncFolderPath)) { + fs.mkdirSync(syncFolderPath, { recursive: true }) + } + const backupsDir = this.getBackupsDirectory(syncFolderPath) + fs.mkdirSync(backupsDir, { recursive: true }) - // 生成临时备份文件路径(防止导入过程中的文件冲突) - const tempDbBackupPath = path.join(syncFolderPath, `chat_${Date.now()}.db.tmp`) - const tempAppSettingsBackupPath = path.join( - syncFolderPath, - `app_settings_${Date.now()}.json.tmp` - ) - const tempProviderModelsBackupPath = path.join( - syncFolderPath, - `provider_models_${Date.now()}.tmp` - ) - const tempMcpSettingsBackupPath = path.join( - syncFolderPath, - `mcp_settings_${Date.now()}.json.tmp` - ) - const tempModelConfigBackupPath = path.join( - syncFolderPath, - `model_config_${Date.now()}.json.tmp` - ) + const timestamp = Date.now() + const backupFileName = `${BACKUP_PREFIX}${timestamp}${BACKUP_EXTENSION}` + const tempZipPath = path.join(backupsDir, `${backupFileName}.tmp`) + const finalZipPath = path.join(backupsDir, backupFileName) - const finalDbBackupPath = path.join(syncFolderPath, 'chat.db') - const finalAppSettingsBackupPath = path.join(syncFolderPath, 'app-settings.json') - const finalProviderModelsBackupPath = path.join(syncFolderPath, 'provider_models') - const finalMcpSettingsBackupPath = path.join(syncFolderPath, 'mcp-settings.json') - const finalModelConfigBackupPath = path.join(syncFolderPath, 'model-config.json') + let completedTimestamp: number | null = null + let encounteredError = false - // 确保数据库文件存在 + try { if (!fs.existsSync(this.DB_PATH)) { - console.warn('数据库文件不存在:', this.DB_PATH) throw new Error('sync.error.dbNotExists') } - // 确保配置文件存在 if (!fs.existsSync(this.APP_SETTINGS_PATH)) { - console.warn('配置文件不存在:', this.APP_SETTINGS_PATH) throw new Error('sync.error.configNotExists') } - // 备份数据库 - fs.copyFileSync(this.DB_PATH, tempDbBackupPath) - - // 备份配置文件(过滤掉同步相关的设置) - if (fs.existsSync(this.APP_SETTINGS_PATH)) { - const appSettingsContent = fs.readFileSync(this.APP_SETTINGS_PATH, 'utf-8') - const appSettings = JSON.parse(appSettingsContent) - - // 创建配置副本,不包含同步相关的设置 - const filteredSettings = { ...appSettings } - // 删除同步相关的设置 - delete filteredSettings.syncEnabled - delete filteredSettings.syncFolderPath - delete filteredSettings.lastSyncTime - - fs.writeFileSync( - tempAppSettingsBackupPath, - JSON.stringify(filteredSettings, null, 2), - 'utf-8' - ) + this.emitBackupStatus('collecting') + const files: Record = {} + files[ZIP_PATHS.db] = new Uint8Array(fs.readFileSync(this.DB_PATH)) + files[ZIP_PATHS.appSettings] = new Uint8Array(fs.readFileSync(this.APP_SETTINGS_PATH)) + this.addOptionalFile(files, ZIP_PATHS.customPrompts, this.CUSTOM_PROMPTS_PATH) + this.addOptionalFile(files, ZIP_PATHS.systemPrompts, this.SYSTEM_PROMPTS_PATH) + this.addOptionalFile(files, ZIP_PATHS.mcpSettings, this.MCP_SETTINGS_PATH) + + const manifest = { + version: 1, + createdAt: timestamp, + files: Object.keys(files) } + files[ZIP_PATHS.manifest] = new Uint8Array( + Buffer.from(JSON.stringify(manifest, null, 2), 'utf-8') + ) - // 备份 MCP 设置 - if (fs.existsSync(this.MCP_SETTINGS_PATH)) { - fs.copyFileSync(this.MCP_SETTINGS_PATH, tempMcpSettingsBackupPath) - } + this.emitBackupStatus('compressing') + const zipData = zipSync(files, { level: 6 }) + fs.writeFileSync(tempZipPath, Buffer.from(zipData)) - // 备份模型配置文件 - if (fs.existsSync(this.MODEL_CONFIG_PATH)) { - fs.copyFileSync(this.MODEL_CONFIG_PATH, tempModelConfigBackupPath) + if (fs.existsSync(finalZipPath)) { + fs.unlinkSync(finalZipPath) } - - // 备份 provider_models 目录 - if (fs.existsSync(this.PROVIDER_MODELS_DIR_PATH)) { - // 确保临时目录存在 - fs.mkdirSync(tempProviderModelsBackupPath, { recursive: true }) - // 复制整个 provider_models 目录 - this.copyDirectory(this.PROVIDER_MODELS_DIR_PATH, tempProviderModelsBackupPath) + this.emitBackupStatus('finalizing') + fs.renameSync(tempZipPath, finalZipPath) + + const backupStats = fs.statSync(finalZipPath) + this.configPresenter.setLastSyncTime(timestamp) + eventBus.send(SYNC_EVENTS.BACKUP_COMPLETED, SendTarget.ALL_WINDOWS, timestamp) + completedTimestamp = timestamp + + return { fileName: backupFileName, createdAt: timestamp, size: backupStats.size } + } catch (error) { + if (fs.existsSync(tempZipPath)) { + fs.unlinkSync(tempZipPath) } + encounteredError = true + this.emitBackupStatus('error', { + message: (error as Error)?.message || 'sync.error.unknown' + }) + throw error + } finally { + this.isBackingUp = false + const extra: Record = {} + if (completedTimestamp) { + extra.lastSuccessfulBackupTime = completedTimestamp + } + if (encounteredError) { + extra.failed = true + } + this.emitBackupStatus('idle', extra) + } + } - // 检查临时文件是否成功创建 - if (!fs.existsSync(tempDbBackupPath)) { - throw new Error('sync.error.tempDbFailed') + private listenForChanges(): void { + const scheduleBackup = () => { + if (!this.configPresenter.getSyncEnabled()) { + return } + if (this.backupTimer) { + clearTimeout(this.backupTimer) + } + this.backupTimer = setTimeout(async () => { + if (!this.isBackingUp) { + try { + await this.performBackup() + } catch (error) { + console.error('auto backup failed:', error) + } + } + }, this.BACKUP_DELAY) + } + + eventBus.on(SYNC_EVENTS.DATA_CHANGED, scheduleBackup) + } + + private getBackupsDirectory(syncFolderPath: string): string { + return syncFolderPath + } + + private emitBackupStatus(status: BackupStatus, extra: Record = {}): void { + eventBus.send(SYNC_EVENTS.BACKUP_STATUS_CHANGED, SendTarget.ALL_WINDOWS, { + status, + previousStatus: this.currentBackupStatus, + ...extra + }) + this.currentBackupStatus = status + } + + private ensureSafeBackupFileName(fileName: string): string { + const normalized = fileName.replace(/\\/g, '/').trim() + if (!normalized) { + throw new Error('sync.error.noValidBackup') + } - if (!fs.existsSync(tempAppSettingsBackupPath)) { - throw new Error('sync.error.tempConfigFailed') + const baseName = path.posix.basename(normalized) + if (baseName !== normalized) { + throw new Error('sync.error.noValidBackup') + } + + if (!BACKUP_FILE_NAME_REGEX.test(baseName)) { + throw new Error('sync.error.noValidBackup') + } + + return baseName + } + + private addOptionalFile( + files: Record, + zipPath: string, + filePath: string + ): void { + if (fs.existsSync(filePath)) { + files[zipPath] = new Uint8Array(fs.readFileSync(filePath)) + } + } + + private extractBackupArchive(zipPath: string, targetDir: string): void { + const zipContent = new Uint8Array(fs.readFileSync(zipPath)) + const extracted = unzipSync(zipContent) + const resolvedTargetDir = path.resolve(targetDir) + + for (const entryName of Object.keys(extracted)) { + const fileContent = extracted[entryName] + if (!fileContent) { + continue } - if (!fs.existsSync(tempMcpSettingsBackupPath)) { - throw new Error('sync.error.tempMcpSettingsFailed') + const normalizedEntry = entryName.replace(/\\/g, '/') + if (!normalizedEntry) { + continue } - // 重命名临时文件为最终文件 - if (fs.existsSync(finalDbBackupPath)) { - fs.unlinkSync(finalDbBackupPath) + if (/^[A-Za-z]:/.test(normalizedEntry) || normalizedEntry.startsWith('/')) { + throw new Error('sync.error.noValidBackup') } - if (fs.existsSync(finalAppSettingsBackupPath)) { - fs.unlinkSync(finalAppSettingsBackupPath) + const segments = normalizedEntry.split('/') + const safeSegments: string[] = [] + for (const segment of segments) { + if (!segment || segment === '.') { + continue + } + if (segment === '..') { + throw new Error('sync.error.noValidBackup') + } + safeSegments.push(segment) } - // 如果存在之前的 provider_models 备份目录,删除它 - if (fs.existsSync(finalProviderModelsBackupPath)) { - this.removeDirectory(finalProviderModelsBackupPath) + if (safeSegments.length === 0) { + continue } - if (fs.existsSync(finalMcpSettingsBackupPath)) { - fs.unlinkSync(finalMcpSettingsBackupPath) + const isDirectoryEntry = normalizedEntry.endsWith('/') + const destination = path.resolve(resolvedTargetDir, ...safeSegments) + const relativeToTarget = path.relative(resolvedTargetDir, destination) + if (relativeToTarget.startsWith('..') || path.isAbsolute(relativeToTarget)) { + throw new Error('sync.error.noValidBackup') } - // 清理之前的模型配置文件备份 - if (fs.existsSync(finalModelConfigBackupPath)) { - fs.unlinkSync(finalModelConfigBackupPath) + if (isDirectoryEntry) { + fs.mkdirSync(destination, { recursive: true }) + continue } - // 确保临时文件存在后再执行重命名 - fs.renameSync(tempDbBackupPath, finalDbBackupPath) - fs.renameSync(tempAppSettingsBackupPath, finalAppSettingsBackupPath) - fs.renameSync(tempMcpSettingsBackupPath, finalMcpSettingsBackupPath) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.writeFileSync(destination, Buffer.from(fileContent)) + } + } + + private mergeAppSettingsPreservingSync(backupPath: string, targetPath: string): void { + if (!fs.existsSync(backupPath)) { + return + } - // 重命名模型配置文件 - if (fs.existsSync(tempModelConfigBackupPath)) { - fs.renameSync(tempModelConfigBackupPath, finalModelConfigBackupPath) - } + let backupSettingsRaw: string + try { + backupSettingsRaw = fs.readFileSync(backupPath, 'utf-8') + } catch (error) { + console.error('Failed to read backup app settings file:', error) + throw new Error('sync.error.noValidBackup') + } - // 重命名 provider_models 临时目录 - if (fs.existsSync(tempProviderModelsBackupPath)) { - fs.renameSync(tempProviderModelsBackupPath, finalProviderModelsBackupPath) + let backupSettings: Record + try { + const parsed = JSON.parse(backupSettingsRaw) + if (!parsed || typeof parsed !== 'object') { + throw new Error('sync.error.noValidBackup') } + backupSettings = parsed as Record + } catch (error) { + console.error('Failed to parse backup app settings JSON:', error) + throw new Error('sync.error.noValidBackup') + } - // 更新最后备份时间 - const now = Date.now() - this.configPresenter.setLastSyncTime(now) + const preservedSettings: Record = {} + preservedSettings.syncEnabled = this.configPresenter.getSyncEnabled() + preservedSettings.syncFolderPath = this.configPresenter.getSyncFolderPath() + preservedSettings.lastSyncTime = this.configPresenter.getLastSyncTime() - // 发送备份完成事件 - eventBus.send(SYNC_EVENTS.BACKUP_COMPLETED, SendTarget.ALL_WINDOWS, now) - } catch (error: unknown) { - console.error('备份过程出错:', error) - eventBus.send( - SYNC_EVENTS.BACKUP_ERROR, - SendTarget.ALL_WINDOWS, - (error as Error).message || 'sync.error.unknown' + const mergedSettings = { + ...backupSettings, + ...Object.fromEntries( + Object.entries(preservedSettings).filter( + ([, value]) => value !== undefined && value !== null + ) ) - throw error - } finally { - // 标记备份结束 - this.isBackingUp = false } + + fs.mkdirSync(path.dirname(targetPath), { recursive: true }) + fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf-8') } - /** - * 监听数据变更事件,触发备份计划 - */ - private listenForChanges(): void { - // 监听多种数据变更事件,使用防抖逻辑触发备份 - const scheduleBackup = () => { - // 如果同步功能未启用,不执行备份 - if (!this.configPresenter.getSyncEnabled()) { - return - } + private createTempBackup(originalPath: string, name: string): string | null { + if (!fs.existsSync(originalPath)) { + return null + } + const tempPath = path.join(app.getPath('temp'), `${name}.${Date.now()}.bak`) + this.copyFile(originalPath, tempPath) + return tempPath + } - // 清除现有定时器 - if (this.backupTimer) { - clearTimeout(this.backupTimer) - } + private copyFile(source: string, target: string): void { + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.copyFileSync(source, target) + } - // 设置新的定时器,延迟执行备份 - this.backupTimer = setTimeout(async () => { - if (!this.isBackingUp) { - try { - await this.performBackup() - } catch (error) { - console.error('自动备份失败:', error) - } + private restoreFromTempBackup(tempFiles: Record): void { + if (tempFiles.db) { + this.copyFile(tempFiles.db, this.DB_PATH) + } + if (tempFiles.appSettings) { + this.copyFile(tempFiles.appSettings, this.APP_SETTINGS_PATH) + } + if (tempFiles.customPrompts) { + this.copyFile(tempFiles.customPrompts, this.CUSTOM_PROMPTS_PATH) + } + if (tempFiles.systemPrompts) { + this.copyFile(tempFiles.systemPrompts, this.SYSTEM_PROMPTS_PATH) + } + if (tempFiles.mcpSettings) { + this.copyFile(tempFiles.mcpSettings, this.MCP_SETTINGS_PATH) + } + } + + private cleanupTempFiles(paths: Array): void { + for (const filePath of paths) { + if (filePath && fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath) + } catch (error) { + console.warn('Failed to remove temp file:', filePath, error) } - }, this.BACKUP_DELAY) + } } + } - // 监听消息相关变更 - eventBus.on(SYNC_EVENTS.DATA_CHANGED, scheduleBackup) + private removeDirectory(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + return + } + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name) + if (entry.isDirectory()) { + this.removeDirectory(entryPath) + } else { + fs.unlinkSync(entryPath) + } + } + fs.rmdirSync(dirPath) } - /** - * 辅助方法:复制目录 - */ - private copyDirectory(source: string, target: string): void { - // 确保目标目录存在 - if (!fs.existsSync(target)) { - fs.mkdirSync(target, { recursive: true }) + private mergePromptStore(backupPath: string, targetPath: string): number { + const backupData = this.readPromptStore(backupPath) + if (!backupData) { + return 0 } + const targetData = this.readPromptStore(targetPath) || { prompts: [] } - // 读取源目录 - const entries = fs.readdirSync(source, { withFileTypes: true }) + const existingIds = new Set(targetData.prompts.map((prompt) => prompt.id).filter(Boolean)) + let added = 0 - // 复制每个文件和子目录 - for (const entry of entries) { - const srcPath = path.join(source, entry.name) - const destPath = path.join(target, entry.name) + for (const prompt of backupData.prompts) { + const id = prompt.id + if (!id || existingIds.has(id)) { + continue + } + targetData.prompts.push(prompt) + existingIds.add(id) + added++ + } - if (entry.isDirectory()) { - // 递归复制子目录 - this.copyDirectory(srcPath, destPath) - } else { - // 复制文件 - fs.copyFileSync(srcPath, destPath) + if (added > 0) { + fs.writeFileSync(targetPath, JSON.stringify(targetData, null, 2), 'utf-8') + } + return added + } + + private readPromptStore(filePath: string): PromptStore | null { + if (!fs.existsSync(filePath)) { + return null + } + try { + const content = fs.readFileSync(filePath, 'utf-8') + const parsed = JSON.parse(content) + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.prompts)) { + return { prompts: [] } } + return parsed as PromptStore + } catch (error) { + console.warn('Failed to read prompt store:', filePath, error) + return { prompts: [] } } } - /** - * 辅助方法:删除目录及其内容 - */ - private removeDirectory(dirPath: string): void { - if (fs.existsSync(dirPath)) { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }) - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name) - if (entry.isDirectory()) { - this.removeDirectory(fullPath) - } else { - fs.unlinkSync(fullPath) + private mergeMcpSettings(backupPath: string, targetPath: string): void { + const backupSettings = this.readMcpSettings(backupPath) + if (!backupSettings) { + return + } + + const currentSettings = this.readMcpSettings(targetPath) || {} + const mergedServers: Record = currentSettings.mcpServers + ? { ...currentSettings.mcpServers } + : {} + + let addedServers = false + for (const [name, config] of Object.entries(backupSettings.mcpServers || {})) { + if (this.isKnowledgeMcp(name, config)) { + continue + } + if (!mergedServers[name]) { + mergedServers[name] = config + addedServers = true + } + } + + const currentDefaults = new Set(currentSettings.defaultServers || []) + let defaultsChanged = false + for (const serverName of backupSettings.defaultServers || []) { + const serverConfig = backupSettings.mcpServers?.[serverName] + if (serverConfig && !this.isKnowledgeMcp(serverName, serverConfig)) { + const beforeSize = currentDefaults.size + currentDefaults.add(serverName) + if (currentDefaults.size !== beforeSize) { + defaultsChanged = true } } + } + + const mergedSettings: McpSettings = { ...currentSettings } + mergedSettings.mcpServers = mergedServers + mergedSettings.defaultServers = Array.from(currentDefaults) + + let settingsChanged = false + for (const [key, value] of Object.entries(backupSettings)) { + if (key === 'mcpServers' || key === 'defaultServers') { + continue + } + if (mergedSettings[key] === undefined) { + mergedSettings[key] = value + settingsChanged = true + } + } + + if (addedServers || defaultsChanged || settingsChanged) { + fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf-8') + return + } + + if (!fs.existsSync(targetPath)) { + fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf-8') + } + } + + private readMcpSettings(filePath: string): McpSettings | null { + if (!fs.existsSync(filePath)) { + return null + } + try { + const content = fs.readFileSync(filePath, 'utf-8') + return JSON.parse(content) as McpSettings + } catch (error) { + console.warn('Failed to read MCP settings:', filePath, error) + return null + } + } - fs.rmdirSync(dirPath) + private isKnowledgeMcp(name: string, config: MCPServerConfig | undefined): boolean { + const normalizedName = name.toLowerCase() + if (normalizedName.includes('knowledge')) { + return true } + const command = typeof config?.command === 'string' ? config.command.toLowerCase() : '' + return command.includes('knowledge') } } diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 9dee0cc10..c3d1180e7 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -533,6 +533,14 @@ export class WindowPresenter implements IWindowPresenter { } } + if (this.settingsWindow && !this.settingsWindow.isDestroyed()) { + try { + this.settingsWindow.webContents.send(channel, ...args) + } catch (error) { + console.error(`Error sending message "${channel}" to settings window:`, error) + } + } + if (this.floatingChatWindow && this.floatingChatWindow.isShowing()) { const floatingWindow = this.floatingChatWindow.getWindow() if (floatingWindow && !floatingWindow.isDestroyed()) { @@ -554,6 +562,21 @@ export class WindowPresenter implements IWindowPresenter { */ sendToWindow(windowId: number, channel: string, ...args: unknown[]): boolean { console.log(`Sending message "${channel}" to window ${windowId}.`) + + if ( + this.settingsWindow && + !this.settingsWindow.isDestroyed() && + this.settingsWindow.id === windowId + ) { + try { + this.settingsWindow.webContents.send(channel, ...args) + return true + } catch (error) { + console.error(`Error sending message "${channel}" to settings window ${windowId}:`, error) + return false + } + } + const window = this.windows.get(windowId) if (window && !window.isDestroyed()) { // 向窗口主 WebContents 发送 diff --git a/src/renderer/settings/App.vue b/src/renderer/settings/App.vue index 63e6be510..eef0c773d 100644 --- a/src/renderer/settings/App.vue +++ b/src/renderer/settings/App.vue @@ -45,13 +45,14 @@ } " /> + diff --git a/src/renderer/src/components/message/MessageBlockContent.vue b/src/renderer/src/components/message/MessageBlockContent.vue index 47204d93a..5de3e13ce 100644 --- a/src/renderer/src/components/message/MessageBlockContent.vue +++ b/src/renderer/src/components/message/MessageBlockContent.vue @@ -54,40 +54,43 @@ watch( () => { nextTick(() => { for (const part of processedContent.value) { - if (part.type === 'artifact' && part.artifact) { - if (props.block.status === 'loading') { - if (artifactStore.currentArtifact?.id === part.artifact.identifier) { - // Use updateArtifactContent to trigger reactivity - artifactStore.updateArtifactContent({ - content: part.content, - title: part.artifact.title, - type: part.artifact.type, - status: part.loading ? 'loading' : 'loaded' - }) - } else { - artifactStore.showArtifact( - { - id: part.artifact.identifier, - type: part.artifact.type, - title: part.artifact.title, - language: part.artifact.language, - content: part.content, - status: part.loading ? 'loading' : 'loaded' - }, - props.messageId, - props.threadId - ) - } + const artifact = part.type === 'artifact' && part.artifact + if (!artifact) continue + const { title, type } = artifact + const { content, loading } = part + if (props.block.status === 'loading') { + const status = loading ? 'loading' : 'loaded' + if (artifactStore.currentArtifact?.id === artifact.identifier) { + // Use updateArtifactContent to trigger reactivity + artifactStore.updateArtifactContent({ + content, + title, + type, + status + }) } else { - if (artifactStore.currentArtifact?.id === part.artifact.identifier) { - // Use updateArtifactContent to trigger reactivity - artifactStore.updateArtifactContent({ - content: part.content, - title: part.artifact.title, - type: part.artifact.type, - status: 'loaded' - }) - } + artifactStore.showArtifact( + { + id: artifact.identifier, + type, + title, + language: artifact.language, + content, + status + }, + props.messageId, + props.threadId + ) + } + } else { + if (artifactStore.currentArtifact?.id === artifact.identifier) { + // Use updateArtifactContent to trigger reactivity + artifactStore.updateArtifactContent({ + content, + title: artifact.title, + type, + status: 'loaded' + }) } } } diff --git a/src/renderer/src/components/message/MessageBlockImage.vue b/src/renderer/src/components/message/MessageBlockImage.vue index b46b7349c..ba402261a 100644 --- a/src/renderer/src/components/message/MessageBlockImage.vue +++ b/src/renderer/src/components/message/MessageBlockImage.vue @@ -66,6 +66,13 @@ import { AssistantMessageBlock } from '@shared/chat' import { useI18n } from 'vue-i18n' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@shadcn/components/ui/dialog' +const keyMap = { + 'image.title': '生成的图片', + 'image.generatedImage': 'AI生成的图片', + 'image.loadError': '图片加载失败', + 'image.viewFull': '查看原图', + 'image.close': '关闭' +} // 创建一个安全的翻译函数 const t = (() => { try { @@ -73,14 +80,7 @@ const t = (() => { return t } catch (e) { // 如果 i18n 未初始化,提供默认翻译 - return (key: string) => { - if (key === 'image.title') return '生成的图片' - if (key === 'image.generatedImage') return 'AI生成的图片' - if (key === 'image.loadError') return '图片加载失败' - if (key === 'image.viewFull') return '查看原图' - if (key === 'image.close') return '关闭' - return key - } + return (key: string) => keyMap[key] || key } })() diff --git a/src/renderer/src/components/message/MessageBlockToolCall.vue b/src/renderer/src/components/message/MessageBlockToolCall.vue index 85b9fef78..b354a6a23 100644 --- a/src/renderer/src/components/message/MessageBlockToolCall.vue +++ b/src/renderer/src/components/message/MessageBlockToolCall.vue @@ -62,6 +62,17 @@ import { AssistantMessageBlock } from '@shared/chat' import { computed, ref } from 'vue' import { JsonObject } from '@/components/json-viewer' +const keyMap = { + 'toolCall.calling': '工具调用中', + 'toolCall.response': '工具响应', + 'toolCall.end': '工具调用完成', + 'toolCall.error': '工具调用错误', + 'toolCall.title': '工具调用', + 'toolCall.clickToView': '点击查看详情', + 'toolCall.functionName': '函数名称', + 'toolCall.params': '参数', + 'toolCall.responseData': '响应数据' +} // 创建一个安全的翻译函数 const t = (() => { try { @@ -69,18 +80,7 @@ const t = (() => { return t } catch (e) { // 如果 i18n 未初始化,提供默认翻译 - return (key: string) => { - if (key === 'toolCall.calling') return '工具调用中' - if (key === 'toolCall.response') return '工具响应' - if (key === 'toolCall.end') return '工具调用完成' - if (key === 'toolCall.error') return '工具调用错误' - if (key === 'toolCall.title') return '工具调用' - if (key === 'toolCall.clickToView') return '点击查看详情' - if (key === 'toolCall.functionName') return '函数名称' - if (key === 'toolCall.params') return '参数' - if (key === 'toolCall.responseData') return '响应数据' - return key - } + return (key: string) => keyMap[key] || key } })() diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index f39aa1018..7f66f68f9 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -25,7 +25,7 @@ :is-search-result="isSearchResult" /> diff --git a/src/renderer/src/components/message/MessageMinimap.vue b/src/renderer/src/components/message/MessageMinimap.vue index 2dcc2ebfc..9b0822dcf 100644 --- a/src/renderer/src/components/message/MessageMinimap.vue +++ b/src/renderer/src/components/message/MessageMinimap.vue @@ -1,6 +1,6 @@