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(