mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
713776a7c4
commit
79e2027b43
4 files changed
+110
-14
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user