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.
This commit is contained in:
digua committed 2026-07-17 00:42:04 +08:00
1 parent 713776a7c4
commit 79e2027b43
4 files changed
+110 -14

No files matched your search

+4 -13
View File
@@ -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)
}
+11
View File
@@ -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())
@@ -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 = `---
@@ -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(