diff --git a/electron/main/ai/llm/index.ts b/electron/main/ai/llm/index.ts index b1ea5105..dee7856b 100644 --- a/electron/main/ai/llm/index.ts +++ b/electron/main/ai/llm/index.ts @@ -10,7 +10,6 @@ import { getAiDataDir } from '../../paths' import type { LLMProvider, ProviderInfo, AIServiceConfig, AIConfigStore } from './types' import { MAX_CONFIG_COUNT } from './types' import { aiLogger } from '../logger' -import { decryptApiKey, isEncrypted } from './crypto' import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' import { buildChatLabUserAgentHeaders } from '../../utils/httpHeaders' import { t } from '../../i18n' @@ -93,69 +92,20 @@ function getConfigPath(): string { return CONFIG_PATH } -// ==================== 旧配置格式(用于迁移)==================== - -interface LegacyStoredConfig { - provider: LLMProvider - apiKey: string - model?: string - maxTokens?: number -} - -function isLegacyConfig(data: unknown): data is LegacyStoredConfig { - if (!data || typeof data !== 'object') return false - const obj = data as Record - return 'provider' in obj && 'apiKey' in obj && !('configs' in obj) -} - -function migrateLegacyConfig(legacy: LegacyStoredConfig): AIConfigStore { - const now = Date.now() - const providerDef = getBuiltinProviderById(legacy.provider) - const newConfig: AIServiceConfig = { - id: randomUUID(), - name: providerDef?.name || legacy.provider, - provider: legacy.provider, - apiKey: legacy.apiKey, - model: legacy.model, - maxTokens: legacy.maxTokens, - createdAt: now, - updatedAt: now, - } - - return { - configs: [newConfig], - defaultAssistant: { configId: newConfig.id, modelId: newConfig.model || '' }, - fastModel: null, - } -} - -// ==================== Schema 版本迁移 ==================== +// ==================== Electron-specific 增强迁移 ==================== +// Migration Runner (packages/config/src/migrations) 处理核心数据迁移。 +// 以下仅处理 Electron 特有的自定义 provider/model 注册(v2 迁移的补充步骤)。 import { addCustomProvider as _addCustomProviderDirect } from './custom-provider-store' import { addCustomModel as _addCustomModelDirect } from './custom-model-store' import { getBuiltinModelById } from './model-catalog' -const CURRENT_SCHEMA_VERSION = 3 - -/** - * MiniMax 等旧 provider 的兼容映射表 - * 这些旧 provider 不在新 BUILTIN_PROVIDERS 中,需要自动迁移为自定义 provider - */ const LEGACY_PROVIDER_FALLBACKS: Record = { minimax: { name: 'MiniMax', defaultBaseUrl: 'https://api.minimaxi.com/v1' }, } -/** - * 将旧 AIConfigStore (schemaVersion=1 或无) 迁移到 schemaVersion=2 - * - provider 在新 registry 中存在 → 保持不变 - * - provider 不存在 → 自动创建自定义 provider - * - model 不在 catalog 中 → 自动创建自定义 model - * - disableThinking / isReasoningModel → 不迁移(兼容期通过 compat 字段保留) - */ -function migrateToSchemaV2(store: AIConfigStore): AIConfigStore { - aiLogger.info('LLM', 'Migrating config store to schema v2') - - for (const config of store.configs) { +function ensureCustomProvidersAndModels(configs: AIServiceConfig[]): void { + for (const config of configs) { const providerId = config.provider if (!getBuiltinProviderById(providerId)) { @@ -170,7 +120,6 @@ function migrateToSchemaV2(store: AIConfigStore): AIConfigStore { supportsCustomModels: true, modelIds: [], }) - aiLogger.info('LLM', `Created custom provider for legacy provider: ${providerId}`) } catch { // already exists } @@ -178,8 +127,7 @@ function migrateToSchemaV2(store: AIConfigStore): AIConfigStore { } if (config.model && getBuiltinProviderById(providerId)) { - const modelInCatalog = getBuiltinModelById(providerId, config.model) - if (!modelInCatalog) { + if (!getBuiltinModelById(providerId, config.model)) { try { _addCustomModelDirect({ id: config.model, @@ -189,39 +137,12 @@ function migrateToSchemaV2(store: AIConfigStore): AIConfigStore { recommendedFor: ['chat'], status: 'stable', }) - aiLogger.info('LLM', `Created custom model "${config.model}" under provider "${providerId}"`) } catch { // already exists } } } } - - return { - ...store, - configs: store.configs.map((c) => { - const { disableThinking: _dt, isReasoningModel: _rm, ...rest } = c as AIServiceConfig & Record - return rest as AIServiceConfig - }), - } -} - -/** - * Schema v2 → v3:activeConfigId → defaultAssistant { configId, modelId } - */ -function migrateToSchemaV3(store: AIConfigStore & { activeConfigId?: string | null }): AIConfigStore { - aiLogger.info('LLM', 'Migrating config store to schema v3 (dual-slot model selection)') - const legacyActiveId = store.activeConfigId ?? null - const resolvedConfig = - legacyActiveId && store.configs.find((c) => c.id === legacyActiveId) - ? store.configs.find((c) => c.id === legacyActiveId)! - : (store.configs[0] ?? null) - - return { - configs: store.configs, - defaultAssistant: resolvedConfig ? { configId: resolvedConfig.id, modelId: resolvedConfig.model || '' } : null, - fastModel: null, - } } /** 解析 ModelSlot:如果 configId 无效,回退到 configs[0] */ @@ -237,7 +158,10 @@ function resolveSlot( // ==================== 多配置管理 ==================== /** - * 加载配置存储(自动处理迁移和解密) + * 加载配置存储 + * + * 数据迁移由 MigrationRunner 在应用启动时统一处理。 + * 此函数只读取最新格式,并从 auth-profiles.json 解析 API Key。 */ export function loadConfigStore(): AIConfigStore { const configPath = getConfigPath() @@ -248,72 +172,23 @@ export function loadConfigStore(): AIConfigStore { try { const content = fs.readFileSync(configPath, 'utf-8') - const data = JSON.parse(content) + const store = JSON.parse(content) as AIConfigStore - if (isLegacyConfig(data)) { - aiLogger.info('LLM', 'Old config format detected, migrating') - const migrated = migrateLegacyConfig(data) - saveConfigStore(migrated) - return loadConfigStore() - } + ensureCustomProvidersAndModels(store.configs) - let store = data as AIConfigStore & { schemaVersion?: number; activeConfigId?: string | null } - let needsSchemaSave = false - - // Schema v1 → v2 迁移 - if (!store.schemaVersion || store.schemaVersion < 2) { - store = { ...migrateToSchemaV2(store), schemaVersion: 2 } as typeof store - needsSchemaSave = true - } - - // Schema v2 → v3 迁移 - if ((store.schemaVersion ?? 0) < 3) { - store = { ...migrateToSchemaV3(store), schemaVersion: CURRENT_SCHEMA_VERSION } as typeof store - needsSchemaSave = true - } - - let needsMigrationSave = false const resolvedConfigs = store.configs.map((config) => { - // 一次性迁移:旧加密 Key → auth-profiles.json - if (config.apiKey && isEncrypted(config.apiKey)) { - const plainKey = decryptApiKey(config.apiKey) - if (plainKey) { - const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider - writeAuthProfile(profileName, { - type: 'api_key', - provider: config.provider, - key: plainKey, - }) - aiLogger.info('LLM', `Migrated API Key for "${config.name}" to auth-profiles.json`) - needsMigrationSave = true - return { ...config, apiKey: plainKey } - } - aiLogger.warn('LLM', `Failed to decrypt API Key for "${config.name}", skipping migration`) - return { ...config, apiKey: '' } - } - - // 正常流程:从 auth-profiles.json 读取 const profileKey = resolveApiKey( config.provider, - (config as Record).authProfile as string | undefined + (config as unknown as Record).authProfile as string | undefined ) return { ...config, apiKey: profileKey || config.apiKey || '' } }) - if (needsMigrationSave || needsSchemaSave) { - aiLogger.info('LLM', 'Saving migrated config store (removing encrypted keys)') - saveConfigStoreRaw({ - ...store, - configs: store.configs.map((config) => ({ - ...config, - apiKey: '', - })), - }) - } - return { ...store, configs: resolvedConfigs, + defaultAssistant: resolveSlot(store.defaultAssistant, resolvedConfigs), + fastModel: resolveSlot(store.fastModel, resolvedConfigs), } } catch (error) { aiLogger.error('LLM', 'Failed to load configs', error) diff --git a/electron/main/index.ts b/electron/main/index.ts index 4541dfad..2ae8ff91 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -12,9 +12,12 @@ import { cleanupPendingDeleteDir, needsUnifiedDirMigration, migrateToUnifiedDirs, + getSystemDataDir, + getAiDataDir, } from './paths' import { migrateAllDatabases, checkMigrationNeeded } from './database/core' import { initLocale } from './i18n' +import { MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config' type AppWithQuitFlag = typeof app & { isQuiting?: boolean } // 统一通过扩展类型访问退出标记,避免使用 @ts-ignore。 @@ -96,6 +99,17 @@ class MainProcess { // 确保应用目录存在 ensureAppDirs() + // 执行配置数据迁移(Migration Runner,Electron 和 CLI 共享) + await new MigrationRunner(ALL_MIGRATIONS, { + dataDir: getSystemDataDir(), + aiDataDir: getAiDataDir(), + logger: { + info: (_cat: string, msg: string) => console.log(`[Migration] ${msg}`), + warn: (_cat: string, msg: string) => console.warn(`[Migration] ${msg}`), + error: (_cat: string, msg: string, ...args: unknown[]) => console.error(`[Migration] ${msg}`, ...args), + }, + }).run() + // 初始化主进程国际化(在 ensureAppDirs 之后,确保 settings 目录存在) await initLocale() diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 1935a004..a78a7b70 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -16,3 +16,5 @@ export { deleteAuthProfile, } from './auth-profiles' export type { AuthProfile, AuthProfilesData } from './auth-profiles' +export { MigrationRunner, ALL_MIGRATIONS } from './migrations' +export type { Migration, MigrationContext, Logger as MigrationLogger } from './migrations' diff --git a/packages/config/src/migrations/crypto-legacy.ts b/packages/config/src/migrations/crypto-legacy.ts new file mode 100644 index 00000000..f3f79053 --- /dev/null +++ b/packages/config/src/migrations/crypto-legacy.ts @@ -0,0 +1,157 @@ +/** + * Legacy crypto 解密模块(仅用于迁移) + * + * 从 electron/main/ai/llm/crypto.ts + device-key.ts 提取, + * 不依赖 Electron API,可在 CLI 和 Electron 中共用。 + */ + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { createDecipheriv, createHash } from 'crypto' +import { execSync } from 'child_process' + +const ENCRYPTED_PREFIX = 'enc:' +const ALGORITHM = 'aes-256-gcm' +const SALT = 'chatlab-api-key-encryption-v1' +const DEVICE_KEY_FILE = '.device-key' + +export function isEncrypted(value: string): boolean { + return value?.startsWith(ENCRYPTED_PREFIX) ?? false +} + +function readKeyFromFile(keyPath: string): string | null { + try { + if (fs.existsSync(keyPath)) { + const key = fs.readFileSync(keyPath, 'utf-8').trim() + if (key.length >= 32) return key + } + } catch { + // ignore + } + return null +} + +function getAllDeviceKeyPaths(): string[] { + const home = os.homedir() + const paths = [path.join(home, '.chatlab', DEVICE_KEY_FILE)] + + if (process.platform === 'darwin') { + paths.push(path.join(home, 'Library', 'Application Support', 'ChatLab', 'data', DEVICE_KEY_FILE)) + } else if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming') + paths.push(path.join(appData, 'ChatLab', 'data', DEVICE_KEY_FILE)) + } else { + paths.push(path.join(home, '.config', 'ChatLab', 'data', DEVICE_KEY_FILE)) + } + + return paths +} + +function getAllDeviceKeys(): string[] { + const keys: string[] = [] + const seen = new Set() + for (const p of getAllDeviceKeyPaths()) { + const k = readKeyFromFile(p) + if (k && !seen.has(k)) { + seen.add(k) + keys.push(k) + } + } + return keys +} + +function deriveLegacyKeys(): Buffer[] { + const keys: Buffer[] = [] + try { + let cmd: string | null = null + if (process.platform === 'linux') { + cmd = '( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname ) | head -n 1 || :' + } else if (process.platform === 'darwin') { + cmd = 'ioreg -rd1 -c IOPlatformExpertDevice' + } else if (process.platform === 'win32') { + cmd = 'REG.exe QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid' + } + + if (cmd) { + const raw = execSync(cmd).toString() + let machineId: string + if (process.platform === 'darwin') { + machineId = + raw + .split('IOPlatformUUID')[1] + ?.split('\n')[0] + ?.replace(/[=\s"]/g, '') + ?.toLowerCase() || '' + } else if (process.platform === 'win32') { + machineId = + raw + .split('REG_SZ')[1] + ?.replace(/[\r\n\s]/g, '') + ?.toLowerCase() || '' + } else { + machineId = raw.replace(/[\r\n\s]/g, '').toLowerCase() + } + + if (machineId) { + const hashed = createHash('sha256').update(machineId).digest('hex') + keys.push( + createHash('sha256') + .update(hashed + SALT) + .digest() + ) + } + } + } catch { + // ignore + } + + keys.push( + createHash('sha256') + .update('chatlab-fallback-key' + SALT) + .digest() + ) + return keys +} + +function tryDecryptWithKey(encrypted: string, key: Buffer): string | null { + try { + const parts = encrypted.slice(ENCRYPTED_PREFIX.length).split(':') + if (parts.length !== 3) return null + + const [ivBase64, authTagBase64, ciphertext] = parts + const iv = Buffer.from(ivBase64, 'base64') + const authTag = Buffer.from(authTagBase64, 'base64') + + const decipher = createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(authTag) + + let decrypted = decipher.update(ciphertext, 'base64', 'utf8') + decrypted += decipher.final('utf8') + return decrypted + } catch { + return null + } +} + +/** + * 解密旧加密 API Key,尝试所有可用的 device key 和 legacy machine-id 密钥 + */ +export function decryptApiKey(encrypted: string): string { + if (!encrypted || !isEncrypted(encrypted)) return encrypted || '' + + for (const dk of getAllDeviceKeys()) { + const derived = createHash('sha256') + .update(dk + SALT) + .digest() + const result = tryDecryptWithKey(encrypted, derived) + if (result !== null) return result + } + + for (const legacyKey of deriveLegacyKeys()) { + const result = tryDecryptWithKey(encrypted, legacyKey) + if (result !== null) return result + } + + return '' +} diff --git a/packages/config/src/migrations/index.ts b/packages/config/src/migrations/index.ts new file mode 100644 index 00000000..d5597c10 --- /dev/null +++ b/packages/config/src/migrations/index.ts @@ -0,0 +1,21 @@ +/** + * Migration Runner 导出 + * + * ALL_MIGRATIONS 按版本号排列,由 MigrationRunner 在应用启动时执行。 + */ + +export { MigrationRunner } from './runner' +export type { Migration, MigrationContext, Logger } from './types' + +import { m001LegacyToMultiConfig } from './m001-legacy-to-multi-config' +import { m002SchemaV2 } from './m002-schema-v2' +import { m003SchemaV3 } from './m003-schema-v3' +import { m004EncryptedKeysToAuthProfiles } from './m004-encrypted-keys-to-auth-profiles' +import type { Migration } from './types' + +export const ALL_MIGRATIONS: Migration[] = [ + m001LegacyToMultiConfig, + m002SchemaV2, + m003SchemaV3, + m004EncryptedKeysToAuthProfiles, +] diff --git a/packages/config/src/migrations/m001-legacy-to-multi-config.ts b/packages/config/src/migrations/m001-legacy-to-multi-config.ts new file mode 100644 index 00000000..daf346a1 --- /dev/null +++ b/packages/config/src/migrations/m001-legacy-to-multi-config.ts @@ -0,0 +1,70 @@ +/** + * Migration v0→v1: Legacy 单配置 → 多配置格式 + * + * 旧格式:{ provider, apiKey, model, ... }(扁平对象) + * 新格式:{ configs: [{ id, name, provider, apiKey, model, ... }], defaultAssistant, fastModel } + */ + +import * as fs from 'fs' +import * as path from 'path' +import { randomUUID } from 'crypto' +import type { Migration, MigrationContext } from './types' + +interface LegacyFlatConfig { + provider: string + apiKey: string + model?: string + maxTokens?: number + [key: string]: unknown +} + +function isLegacyFlatConfig(data: unknown): data is LegacyFlatConfig { + if (!data || typeof data !== 'object') return false + const obj = data as Record + return 'provider' in obj && 'apiKey' in obj && !('configs' in obj) +} + +export const m001LegacyToMultiConfig: Migration = { + version: 1, + name: 'legacy-to-multi-config', + description: 'Convert legacy flat LLM config to multi-config format', + + async up(ctx: MigrationContext) { + const configPath = path.join(ctx.aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) return + + const raw = fs.readFileSync(configPath, 'utf-8') + let data: unknown + try { + data = JSON.parse(raw) + } catch { + return + } + + if (!isLegacyFlatConfig(data)) return + + ctx.logger.info('Migration', 'Converting legacy flat config to multi-config format') + + const now = Date.now() + const newConfig = { + id: randomUUID(), + name: data.provider, + provider: data.provider, + apiKey: data.apiKey || '', + model: data.model || '', + maxTokens: data.maxTokens, + createdAt: now, + updatedAt: now, + } + + const migrated = { + schemaVersion: 1, + configs: [newConfig], + defaultAssistant: { configId: newConfig.id, modelId: newConfig.model }, + fastModel: null, + } + + fs.writeFileSync(configPath, JSON.stringify(migrated, null, 2), 'utf-8') + ctx.logger.info('Migration', 'Legacy config migrated to multi-config format') + }, +} diff --git a/packages/config/src/migrations/m002-schema-v2.ts b/packages/config/src/migrations/m002-schema-v2.ts new file mode 100644 index 00000000..f40f027a --- /dev/null +++ b/packages/config/src/migrations/m002-schema-v2.ts @@ -0,0 +1,48 @@ +/** + * Migration v1→v2: Schema v2 升级 + * + * - 移除 configs 中的 disableThinking / isReasoningModel 字段 + * - 更新 schemaVersion 为 2 + * + * 注意:原 Electron 版还包含自定义 provider/model 创建逻辑, + * 该逻辑依赖 Electron-specific 模块,在共享 migration 中跳过。 + * Electron 端在 loadConfigStore 中已有回退处理。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { Migration, MigrationContext } from './types' + +export const m002SchemaV2: Migration = { + version: 2, + name: 'schema-v2', + description: 'Upgrade LLM config to schema v2 (remove deprecated fields)', + + async up(ctx: MigrationContext) { + const configPath = path.join(ctx.aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) return + + let data: Record + try { + data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + } catch { + return + } + + const schemaVersion = (data.schemaVersion as number) || 0 + if (schemaVersion >= 2) return + + ctx.logger.info('Migration', 'Upgrading LLM config to schema v2') + + const configs = (data.configs as Record[]) || [] + const cleanedConfigs = configs.map((c) => { + const { disableThinking: _dt, isReasoningModel: _rm, ...rest } = c + return rest + }) + + data.configs = cleanedConfigs + data.schemaVersion = 2 + + fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') + }, +} diff --git a/packages/config/src/migrations/m003-schema-v3.ts b/packages/config/src/migrations/m003-schema-v3.ts new file mode 100644 index 00000000..94e57ae4 --- /dev/null +++ b/packages/config/src/migrations/m003-schema-v3.ts @@ -0,0 +1,49 @@ +/** + * Migration v2→v3: Schema v3 升级 — 双 slot 模型选择 + * + * - 将 activeConfigId → defaultAssistant { configId, modelId } + * - 新增 fastModel slot(默认 null) + * - 更新 schemaVersion 为 3 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { Migration, MigrationContext } from './types' + +export const m003SchemaV3: Migration = { + version: 3, + name: 'schema-v3-dual-slot', + description: 'Upgrade LLM config to schema v3 (dual-slot model selection)', + + async up(ctx: MigrationContext) { + const configPath = path.join(ctx.aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) return + + let data: Record + try { + data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + } catch { + return + } + + const schemaVersion = (data.schemaVersion as number) || 0 + if (schemaVersion >= 3) return + + ctx.logger.info('Migration', 'Upgrading LLM config to schema v3 (dual-slot)') + + const configs = (data.configs as Array<{ id: string; model?: string }>) || [] + const activeConfigId = data.activeConfigId as string | null | undefined + + const resolvedConfig = + activeConfigId && configs.find((c) => c.id === activeConfigId) + ? configs.find((c) => c.id === activeConfigId)! + : (configs[0] ?? null) + + data.defaultAssistant = resolvedConfig ? { configId: resolvedConfig.id, modelId: resolvedConfig.model || '' } : null + data.fastModel = data.fastModel ?? null + data.schemaVersion = 3 + delete data.activeConfigId + + fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') + }, +} diff --git a/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts b/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts new file mode 100644 index 00000000..32091407 --- /dev/null +++ b/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts @@ -0,0 +1,70 @@ +/** + * Migration v3→v4: 加密 API Key → auth-profiles.json + * + * - 从 llm-config.json 读取加密的 apiKey(enc: 前缀) + * - 使用 device-key + legacy machine-id 尝试解密 + * - 解密成功的写入 auth-profiles.json + * - 清空 llm-config.json 中的 apiKey 字段 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { Migration, MigrationContext } from './types' +import { isEncrypted, decryptApiKey } from './crypto-legacy' +import { writeAuthProfile } from '../auth-profiles' + +export const m004EncryptedKeysToAuthProfiles: Migration = { + version: 4, + name: 'encrypted-keys-to-auth-profiles', + description: 'Migrate encrypted API keys from llm-config.json to auth-profiles.json', + + async up(ctx: MigrationContext) { + const configPath = path.join(ctx.aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) return + + let data: Record + try { + data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + } catch { + return + } + + const configs = (data.configs as Array>) || [] + let migrated = false + + for (const config of configs) { + const apiKey = config.apiKey as string + if (!apiKey) continue + + if (isEncrypted(apiKey)) { + const plainKey = decryptApiKey(apiKey) + if (plainKey) { + const provider = (config.provider as string) || 'unknown' + const name = (config.name as string) || provider + const profileName = name.toLowerCase().replace(/\s+/g, '-') + + writeAuthProfile(profileName, { type: 'api_key', provider, key: plainKey }) + config.apiKey = '' + migrated = true + ctx.logger.info('Migration', `Migrated API key for "${name}" to auth-profiles.json`) + } else { + ctx.logger.warn('Migration', `Failed to decrypt API key for "${config.name}", skipping`) + } + } else if (apiKey.length > 0) { + const provider = (config.provider as string) || 'unknown' + const name = (config.name as string) || provider + const profileName = name.toLowerCase().replace(/\s+/g, '-') + + writeAuthProfile(profileName, { type: 'api_key', provider, key: apiKey }) + config.apiKey = '' + migrated = true + ctx.logger.info('Migration', `Migrated plaintext API key for "${name}" to auth-profiles.json`) + } + } + + if (migrated) { + fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') + ctx.logger.info('Migration', 'Cleared API keys from llm-config.json') + } + }, +} diff --git a/packages/config/src/migrations/runner.ts b/packages/config/src/migrations/runner.ts new file mode 100644 index 00000000..e5a6456c --- /dev/null +++ b/packages/config/src/migrations/runner.ts @@ -0,0 +1,78 @@ +/** + * MigrationRunner + * + * 在应用启动时执行所有待执行的数据迁移。 + * 版本号存储在 ~/.chatlab/.migration-version 文件中。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { Migration, MigrationContext } from './types' + +const VERSION_FILE = '.migration-version' + +export class MigrationRunner { + private migrations: Migration[] + private context: MigrationContext + + constructor(migrations: Migration[], context: MigrationContext) { + this.migrations = [...migrations].sort((a, b) => a.version - b.version) + this.context = context + } + + getCurrentVersion(): number { + const versionPath = path.join(this.context.dataDir, VERSION_FILE) + try { + if (fs.existsSync(versionPath)) { + const raw = fs.readFileSync(versionPath, 'utf-8').trim() + const ver = parseInt(raw, 10) + return Number.isFinite(ver) ? ver : 0 + } + } catch { + // version file unreadable, treat as 0 + } + return 0 + } + + private writeVersion(version: number): void { + const versionPath = path.join(this.context.dataDir, VERSION_FILE) + try { + if (!fs.existsSync(this.context.dataDir)) { + fs.mkdirSync(this.context.dataDir, { recursive: true }) + } + fs.writeFileSync(versionPath, String(version), 'utf-8') + } catch (err) { + this.context.logger.error('Migration', `Failed to write version file: ${err}`) + } + } + + async run(): Promise<{ executed: number; currentVersion: number }> { + const currentVersion = this.getCurrentVersion() + const pending = this.migrations.filter((m) => m.version > currentVersion) + + if (pending.length === 0) { + return { executed: 0, currentVersion } + } + + this.context.logger.info('Migration', `${pending.length} pending migration(s), current version: ${currentVersion}`) + + let lastVersion = currentVersion + let executed = 0 + + for (const migration of pending) { + this.context.logger.info('Migration', `Running: v${lastVersion}→v${migration.version} ${migration.name}`) + try { + await migration.up(this.context) + lastVersion = migration.version + this.writeVersion(lastVersion) + executed++ + this.context.logger.info('Migration', `Completed: ${migration.name}`) + } catch (err) { + this.context.logger.error('Migration', `Failed: ${migration.name}`, err) + break + } + } + + return { executed, currentVersion: lastVersion } + } +} diff --git a/packages/config/src/migrations/types.ts b/packages/config/src/migrations/types.ts new file mode 100644 index 00000000..cdb85465 --- /dev/null +++ b/packages/config/src/migrations/types.ts @@ -0,0 +1,28 @@ +/** + * Migration Runner 类型定义 + */ + +export interface Logger { + info(category: string, message: string, ...args: unknown[]): void + warn(category: string, message: string, ...args: unknown[]): void + error(category: string, message: string, ...args: unknown[]): void +} + +export interface MigrationContext { + /** ~/.chatlab */ + dataDir: string + /** ~/.chatlab/ai */ + aiDataDir: string + logger: Logger +} + +export interface Migration { + /** 迁移后的目标版本号(单调递增) */ + version: number + /** 迁移名称(用于日志) */ + name: string + /** 迁移描述 */ + description: string + /** 执行迁移(须幂等) */ + up(context: MigrationContext): Promise +} diff --git a/packages/server/src/ai/agent.ts b/packages/server/src/ai/agent.ts index 326bbac5..21da6f40 100644 --- a/packages/server/src/ai/agent.ts +++ b/packages/server/src/ai/agent.ts @@ -7,12 +7,7 @@ import { Agent as PiAgentCore } from '@mariozechner/pi-agent-core' import type { AgentEvent as PiAgentEvent } from '@mariozechner/pi-agent-core' -import { - type AssistantMessage as PiAssistantMessage, - type Message as PiMessage, - type Usage as PiUsage, - streamSimple, -} from '@mariozechner/pi-ai' +import { type Message as PiMessage, type Usage as PiUsage, streamSimple } from '@mariozechner/pi-ai' import type { AIConversationManager } from '@openchatlab/node-runtime' import { getDefaultAssistantConfig, buildPiModel } from './llm-config' @@ -52,7 +47,7 @@ export interface RunAgentOptions { type SimpleHistoryMessage = { role: 'user' | 'assistant' | 'summary'; content: string } function buildSystemPrompt( - chatType: 'group' | 'private', + _chatType: 'group' | 'private', assistantSystemPrompt?: string, locale: string = 'zh-CN' ): string { @@ -117,16 +112,6 @@ function toPiHistoryMessages(messages: SimpleHistoryMessage[]): PiMessage[] { }) } -function stripThinkingTags(content: string): string { - const tags = ['think', 'analysis', 'reasoning', 'reflection', 'thought', 'thinking'] - const pattern = tags.join('|') - return content.replace(new RegExp(`<(${pattern})>[\\s\\S]*?<\\/\\1>`, 'gi'), '').trim() -} - -function stripToolCallTags(content: string): string { - return content.replace(/[\s\S]*?<\/tool_call>/gi, '').trim() -} - export async function runServerAgent(options: RunAgentOptions): Promise { const { userMessage, diff --git a/packages/server/src/ai/tool-adapter.ts b/packages/server/src/ai/tool-adapter.ts index 74cb7338..6226cbbc 100644 --- a/packages/server/src/ai/tool-adapter.ts +++ b/packages/server/src/ai/tool-adapter.ts @@ -4,7 +4,7 @@ * 将 @openchatlab/tools 的 ToolDefinition 适配为 @mariozechner/pi-agent-core 的 AgentTool 格式。 */ -import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core' import type { ToolDefinition, ToolExecutionContext } from '@openchatlab/tools' import type { DatabaseAdapter } from '@openchatlab/core' @@ -14,24 +14,28 @@ export interface ServerToolContext { locale?: string } -function convertJsonSchemaToParameters(schema: ToolDefinition['inputSchema']): AgentTool['parameters'] { +function convertJsonSchemaToParameters(schema: ToolDefinition['inputSchema']) { const properties: Record = {} for (const [key, prop] of Object.entries(schema.properties)) { properties[key] = { ...prop } } return { - type: 'object', + type: 'object' as const, properties, required: schema.required || [], } } -export function adaptToolsForAgent(tools: ToolDefinition[], getContext: () => ServerToolContext): AgentTool[] { +export function adaptToolsForAgent( + tools: ToolDefinition[], + getContext: () => ServerToolContext +): AgentTool[] { return tools.map((tool) => ({ name: tool.name, + label: tool.name, description: tool.description, - parameters: convertJsonSchemaToParameters(tool.inputSchema), - async execute(params: Record): Promise { + parameters: convertJsonSchemaToParameters(tool.inputSchema) as any, + async execute(_toolCallId: string, params: Record): Promise> { const ctx = getContext() const execCtx: ToolExecutionContext = { db: ctx.db, @@ -40,9 +44,10 @@ export function adaptToolsForAgent(tools: ToolDefinition[], getContext: () => Se } try { const result = tool.handler(params, execCtx) - return result.content + return { content: [{ type: 'text', text: result.content }], details: null } } catch (error) { - return `Error: ${error instanceof Error ? error.message : String(error)}` + const msg = error instanceof Error ? error.message : String(error) + return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null } } }, })) diff --git a/packages/server/src/http/index.ts b/packages/server/src/http/index.ts index 0a105b9c..a8cfaf07 100644 --- a/packages/server/src/http/index.ts +++ b/packages/server/src/http/index.ts @@ -9,7 +9,7 @@ import * as fs from 'fs' import * as path from 'path' import * as crypto from 'crypto' import type { FastifyInstance } from 'fastify' -import { loadConfig, getConfigDir } from '@openchatlab/config' +import { loadConfig, getConfigDir, MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config' import type { ChatLabConfig } from '@openchatlab/config' import { NodePathProvider, DatabaseManager, AIConversationManager } from '@openchatlab/node-runtime' import { createServer } from './server' @@ -83,6 +83,17 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ const userDataDir = config.data.user_data_dir || undefined const pathProvider = new NodePathProvider(userDataDir) pathProvider.ensureAllDirs() + + const migrationRunner = new MigrationRunner(ALL_MIGRATIONS, { + dataDir: pathProvider.getSystemDir(), + aiDataDir: pathProvider.getAiDataDir(), + logger: { + info: (_cat: string, msg: string) => console.log(`[Migration] ${msg}`), + warn: (_cat: string, msg: string) => console.warn(`[Migration] ${msg}`), + error: (_cat: string, msg: string, ...args: unknown[]) => console.error(`[Migration] ${msg}`, ...args), + }, + }) + await migrationRunner.run() const nativeBinding = resolveNativeBinding() dbManager = new DatabaseManager(pathProvider, { nativeBinding }) convManager = new AIConversationManager(pathProvider.getAiDataDir(), { nativeBinding })