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:
digua committed 2026-05-14 21:14:15 +08:00
1 parent b479fd3b2d
commit 6352a3f191
14 files changed
+579 -166

No files matched your search

+15 -140
View File
@@ -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)
+14
View File
@@ -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()
+2
View File
@@ -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 ''
}
+21
View File
@@ -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')
}
},
}
+78
View File
@@ -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 }
}
}
+28
View File
@@ -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>
}
+2 -17
View File
@@ -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,
+13 -8
View File
@@ -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 }
}
},
}))
+12 -1
View File
@@ -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 })