From 79e2027b438762d682fd2bb5486351adde5545d2 Mon Sep 17 00:00:00 2001 From: digua Date: Fri, 17 Jul 2026 00:29:05 +0800 Subject: [PATCH] fix(ai): initialize and migrate default assistants Load standalone CLI assistant configs through the shared manager so fresh data directories receive locale defaults. Back up custom assistants that occupy newly reserved default IDs before installing canonical templates. --- apps/cli/src/ai/assistant-loader.ts | 17 ++---- apps/cli/src/ai/manager-factory.test.ts | 11 ++++ .../ai/__tests__/assistant-manager.test.ts | 58 +++++++++++++++++++ .../node-runtime/src/ai/assistant-manager.ts | 38 +++++++++++- 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/ai/assistant-loader.ts b/apps/cli/src/ai/assistant-loader.ts index 76815f74..1f700068 100644 --- a/apps/cli/src/ai/assistant-loader.ts +++ b/apps/cli/src/ai/assistant-loader.ts @@ -1,22 +1,13 @@ /** * 助手配置加载器 * - * 从 ~/.chatlab/ai/assistants/*.md 加载助手系统提示词。 + * 通过共享 AssistantManager 加载配置,确保 standalone CLI 在首次使用时 + * 也会创建默认助手,而不是绕过初始化直接读取磁盘。 */ -import * as fs from 'fs' -import * as path from 'path' -import { parseAssistantFile } from '@openchatlab/node-runtime' import type { AssistantConfig } from '@openchatlab/node-runtime' +import { getAssistantManager } from './manager-factory' export function loadAssistantConfig(aiDataDir: string, assistantId: string): AssistantConfig | null { - const filePath = path.join(aiDataDir, 'assistants', `${assistantId}.md`) - if (!fs.existsSync(filePath)) return null - - try { - const content = fs.readFileSync(filePath, 'utf-8') - return parseAssistantFile(content, filePath) - } catch { - return null - } + return getAssistantManager(aiDataDir).getAssistantConfig(assistantId) } diff --git a/apps/cli/src/ai/manager-factory.test.ts b/apps/cli/src/ai/manager-factory.test.ts index e338977a..041a87c2 100644 --- a/apps/cli/src/ai/manager-factory.test.ts +++ b/apps/cli/src/ai/manager-factory.test.ts @@ -5,6 +5,7 @@ import * as os from 'node:os' import * as path from 'node:path' import { GENERAL_ASSISTANT_IDS } from '@openchatlab/shared-types' import { getAssistantManager } from './manager-factory' +import { loadAssistantConfig } from './assistant-loader' const tempDirs: string[] = [] @@ -22,6 +23,16 @@ afterEach(() => { }) describe('CLI assistant manager', () => { + it('lazily initializes shared defaults when standalone chat loads an assistant', () => { + const aiDataDir = createTempDir() + + const assistant = loadAssistantConfig(aiDataDir, 'general_en') + + assert.equal(assistant?.id, 'general_en') + assert.ok(assistant?.systemPrompt) + assert.equal(fs.existsSync(path.join(aiDataDir, 'assistants', 'general_en.md')), true) + }) + it('initializes all shared defaults and can reset a customized default', () => { const manager = getAssistantManager(createTempDir()) diff --git a/packages/node-runtime/src/ai/__tests__/assistant-manager.test.ts b/packages/node-runtime/src/ai/__tests__/assistant-manager.test.ts index e0595c67..20ab4b17 100644 --- a/packages/node-runtime/src/ai/__tests__/assistant-manager.test.ts +++ b/packages/node-runtime/src/ai/__tests__/assistant-manager.test.ts @@ -123,6 +123,64 @@ describe('AssistantManager', () => { assert.equal(result2.generalUpdated, false) }) + it('backs up a custom assistant before reserving a newly introduced general id', () => { + const fs = createMemoryFs() + fs.files.set( + '/data/assistants/general_tw.md', + serializeAssistant({ + id: 'general_tw', + name: 'My Traditional Chinese Assistant', + systemPrompt: 'Keep this custom prompt.', + presetQuestions: ['Custom question'], + }) + ) + const traditionalChineseBuiltin = SAMPLE_BUILTIN.replaceAll('general_cn', 'general_tw') + const ctx = createManager({ + fs, + builtins: [{ id: 'general_tw', content: traditionalChineseBuiltin }], + generalIds: ['general_tw'], + }) + + const result = ctx.manager.init() + + assert.equal(result.generalCreated, true) + assert.equal(ctx.manager.getAssistantConfig('general_tw')?.builtinId, 'general_tw') + assert.equal(ctx.manager.getAssistantConfig('general_tw')?.systemPrompt, '你是一个通用助手。') + assert.equal(ctx.manager.getAssistantConfig('custom_1')?.systemPrompt, 'Keep this custom prompt.') + assert.equal(ctx.manager.getAssistantConfig('custom_1')?.builtinId, undefined) + assert.equal(ctx.manager.deleteAssistant('custom_1').success, true) + }) + + it('keeps the custom assistant backup if writing the reserved default fails', () => { + const fs = createMemoryFs() + const reservedPath = '/data/assistants/general_tw.md' + fs.files.set( + reservedPath, + serializeAssistant({ + id: 'general_tw', + name: 'My Traditional Chinese Assistant', + systemPrompt: 'Keep this custom prompt.', + presetQuestions: [], + }) + ) + const writeFile = fs.writeFile + fs.writeFile = (filePath, content) => { + if (filePath === reservedPath) throw new Error('Default write failed') + writeFile(filePath, content) + } + const ctx = createManager({ + fs, + builtins: [{ id: 'general_tw', content: SAMPLE_BUILTIN.replaceAll('general_cn', 'general_tw') }], + generalIds: ['general_tw'], + }) + + const result = ctx.manager.init() + + assert.equal(result.generalCreated, false) + assert.equal(ctx.manager.getAssistantConfig('general_tw')?.systemPrompt, 'Keep this custom prompt.') + assert.equal(ctx.manager.getAssistantConfig('custom_1')?.systemPrompt, 'Keep this custom prompt.') + }) + it('upgrades an unmodified legacy general assistant', () => { const legacyConfig = parseAssistantFile(SAMPLE_BUILTIN, 'general_cn.md')! const nextBuiltin = `--- diff --git a/packages/node-runtime/src/ai/assistant-manager.ts b/packages/node-runtime/src/ai/assistant-manager.ts index fba1a54f..d7a52ff4 100644 --- a/packages/node-runtime/src/ai/assistant-manager.ts +++ b/packages/node-runtime/src/ai/assistant-manager.ts @@ -134,7 +134,29 @@ export class AssistantManager { try { const existing = parseAssistantFile(fs.readFile(filePath), filePath) - if (!existing || existing.builtinId !== id || !this.shouldUpgradeBuiltin(existing, config)) continue + if (!existing) continue + + if (existing.builtinId !== id) { + try { + const backupId = this.backupAssistantOccupyingReservedId(existing) + fs.writeFile(filePath, serializeAssistant(this.withBuiltinTracking(config))) + generalCreated = true + this.deps.logger?.info('AssistantManager', 'Backed up assistant occupying reserved default ID', { + reservedId: id, + backupId, + }) + } catch (error) { + // 先写备份再替换默认助手;任一步失败都中止本次迁移,避免静默丢失用户配置。 + this.deps.logger?.error( + 'AssistantManager', + `Failed to migrate assistant occupying reserved default ID: ${id}`, + error + ) + } + continue + } + + if (!this.shouldUpgradeBuiltin(existing, config)) continue fs.writeFile(filePath, serializeAssistant(this.withBuiltinTracking(config, existing.id))) generalUpdated = true @@ -153,6 +175,20 @@ export class AssistantManager { return { generalCreated, generalUpdated } } + private backupAssistantOccupyingReservedId(config: AssistantConfig): string { + const { fs, assistantsDir } = this.deps + const baseId = this.deps.generateId?.() || `custom_${Date.now().toString(36)}` + let backupId = baseId + let suffix = 2 + + while (this.generalIds.includes(backupId) || fs.fileExists(fs.joinPath(assistantsDir, `${backupId}.md`))) { + backupId = `${baseId}_${suffix++}` + } + + fs.writeFile(fs.joinPath(assistantsDir, `${backupId}.md`), serializeAssistant({ ...config, id: backupId })) + return backupId + } + private getConfigDigest(config: AssistantConfig): string { // Hash only user-editable template content; tracking metadata must not affect comparison. return this.deps.contentHash(