mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor: implement Migration Runner for config data migrations
Extract migration logic from loadConfigStore into a shared Migration Runner system (packages/config/src/migrations/). Runs at app startup in both Electron and CLI, enabling CLI to handle encrypted key migration independently.
This commit is contained in:
1 parent
b479fd3b2d
commit
6352a3f191
14 files changed
+579
-166
No files matched your search
+15
-140
@@ -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<string, unknown>
|
||||
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<string, { name: string; defaultBaseUrl: string }> = {
|
||||
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<string, unknown>
|
||||
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<string, unknown>).authProfile as string | undefined
|
||||
(config as unknown as Record<string, unknown>).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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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<string>()
|
||||
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 ''
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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<string, unknown>
|
||||
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')
|
||||
},
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>[]) || []
|
||||
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')
|
||||
},
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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')
|
||||
},
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const configs = (data.configs as Array<Record<string, unknown>>) || []
|
||||
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')
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
@@ -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(/<tool_call>[\s\S]*?<\/tool_call>/gi, '').trim()
|
||||
}
|
||||
|
||||
export async function runServerAgent(options: RunAgentOptions): Promise<void> {
|
||||
const {
|
||||
userMessage,
|
||||
|
||||
@@ -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<string, unknown> = {}
|
||||
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<any, any>[] {
|
||||
return tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
label: tool.name,
|
||||
description: tool.description,
|
||||
parameters: convertJsonSchemaToParameters(tool.inputSchema),
|
||||
async execute(params: Record<string, unknown>): Promise<string> {
|
||||
parameters: convertJsonSchemaToParameters(tool.inputSchema) as any,
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>): Promise<AgentToolResult<unknown>> {
|
||||
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 }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user