feat(node-runtime): extract AssistantManager class with abstract FS

Platform-agnostic AssistantManager with dependency injection for file
system ops, builtin configs, and ID generation. Supports init, CRUD,
builtin import/reimport/reset, and cloud markdown import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-05-18 00:10:52 +08:00
1 parent 7feab45447
commit cb9849c164
4 files changed
+461

No files matched your search

@@ -0,0 +1,152 @@
import { describe, it, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { AssistantManager, type AssistantManagerFs, type AssistantManagerDeps } from '../assistant-manager'
function createMemoryFs(): AssistantManagerFs & { files: Map<string, string> } {
const files = new Map<string, string>()
return {
files,
ensureDir: () => {
/* no-op */
},
listFiles: (_dir, ext) =>
Array.from(files.keys())
.filter((f) => f.endsWith(ext))
.map((f) => f.split('/').pop()!),
readFile: (p) => {
const content = files.get(p)
if (!content) throw new Error(`File not found: ${p}`)
return content
},
writeFile: (p, content) => files.set(p, content),
deleteFile: (p) => files.delete(p),
fileExists: (p) => files.has(p),
joinPath: (...parts) => parts.join('/'),
}
}
const SAMPLE_BUILTIN = `---
id: general_cn
name: 通用助手
presetQuestions:
- 你好
---
你是一个通用助手。`
function createManager(opts?: { builtins?: Array<{ id: string; content: string }>; generalIds?: string[] }): {
manager: AssistantManager
fs: ReturnType<typeof createMemoryFs>
} {
const memFs = createMemoryFs()
let idCounter = 0
const deps: AssistantManagerDeps = {
fs: memFs,
assistantsDir: '/data/assistants',
builtinRawConfigs: opts?.builtins || [{ id: 'general_cn', content: SAMPLE_BUILTIN }],
generalIds: opts?.generalIds || ['general_cn'],
generateId: () => `custom_${++idCounter}`,
}
return { manager: new AssistantManager(deps), fs: memFs }
}
describe('AssistantManager', () => {
let manager: AssistantManager
let memFs: ReturnType<typeof createMemoryFs>
beforeEach(() => {
const ctx = createManager()
manager = ctx.manager
memFs = ctx.fs
})
it('initializes and creates general assistants', () => {
const result = manager.init()
assert.ok(result.generalCreated)
assert.equal(result.total, 1)
assert.ok(memFs.files.has('/data/assistants/general_cn.md'))
})
it('does not recreate general if already exists', () => {
manager.init()
const result2 = manager.init()
assert.equal(result2.generalCreated, false)
})
it('getAllAssistants returns summaries', () => {
manager.init()
const all = manager.getAllAssistants()
assert.equal(all.length, 1)
assert.equal(all[0].name, '通用助手')
})
it('getAssistantConfig returns full config', () => {
manager.init()
const config = manager.getAssistantConfig('general_cn')
assert.ok(config)
assert.equal(config!.systemPrompt, '你是一个通用助手。')
})
it('creates a custom assistant', () => {
manager.init()
const result = manager.createAssistant({
name: 'Custom',
systemPrompt: 'Be helpful.',
presetQuestions: [],
})
assert.ok(result.success)
assert.ok(result.id)
assert.equal(manager.getAllAssistants().length, 2)
})
it('updates an assistant', () => {
manager.init()
manager.createAssistant({ name: 'Old', systemPrompt: 'x', presetQuestions: [] })
const result = manager.updateAssistant('custom_1', { name: 'New' })
assert.ok(result.success)
assert.equal(manager.getAssistantConfig('custom_1')!.name, 'New')
})
it('deletes a non-general assistant', () => {
manager.init()
manager.createAssistant({ name: 'ToDelete', systemPrompt: 'x', presetQuestions: [] })
const result = manager.deleteAssistant('custom_1')
assert.ok(result.success)
assert.equal(manager.hasAssistant('custom_1'), false)
})
it('refuses to delete general assistant', () => {
manager.init()
const result = manager.deleteAssistant('general_cn')
assert.equal(result.success, false)
assert.ok(result.error)
})
it('resets a builtin assistant', () => {
manager.init()
manager.updateAssistant('general_cn', { name: 'Modified' })
assert.equal(manager.getAssistantConfig('general_cn')!.name, 'Modified')
const result = manager.resetAssistant('general_cn')
assert.ok(result.success)
assert.equal(manager.getAssistantConfig('general_cn')!.name, '通用助手')
})
it('imports from raw markdown', () => {
manager.init()
const md = `---
id: cloud_test
name: Cloud Assistant
presetQuestions: []
---
Cloud system prompt.`
const result = manager.importAssistantFromMd(md)
assert.ok(result.success)
assert.equal(result.id, 'cloud_test')
assert.equal(manager.hasAssistant('cloud_test'), true)
})
it('isGeneralAssistant checks correctly', () => {
assert.ok(manager.isGeneralAssistant('general_cn'))
assert.ok(!manager.isGeneralAssistant('custom_1'))
})
})
@@ -0,0 +1,289 @@
/**
* Platform-agnostic assistant manager.
* Abstracts file system operations and builtin resource loading
* via dependency injection.
*/
import { parseAssistantFile, serializeAssistant } from './assistant-parser'
import type { AssistantConfig, AssistantSummary } from './types'
// ==================== Result types ====================
export interface AssistantInitResult {
total: number
generalCreated: boolean
}
export interface AssistantSaveResult {
success: boolean
error?: string
}
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
imported: boolean
}
// ==================== Dependency abstraction ====================
export interface AssistantManagerFs {
ensureDir(dir: string): void
listFiles(dir: string, ext: string): string[]
readFile(filePath: string): string
writeFile(filePath: string, content: string): void
deleteFile(filePath: string): void
fileExists(filePath: string): boolean
joinPath(...parts: string[]): string
}
export interface AssistantManagerDeps {
fs: AssistantManagerFs
assistantsDir: string
builtinRawConfigs?: Array<{ id: string; content: string }>
generalIds?: string[]
generateId?: () => string
logger?: {
info: (category: string, message: string, data?: unknown) => void
warn: (category: string, message: string, data?: unknown) => void
error: (category: string, message: string, data?: unknown) => void
}
}
// ==================== Manager ====================
function toSummary(config: AssistantConfig): AssistantSummary {
return {
id: config.id,
name: config.name,
systemPrompt: config.systemPrompt,
presetQuestions: config.presetQuestions,
builtinId: config.builtinId,
applicableChatTypes: config.applicableChatTypes,
supportedLocales: config.supportedLocales,
}
}
export class AssistantManager {
private deps: AssistantManagerDeps
private generalIds: string[]
private builtinCache = new Map<string, AssistantConfig>()
private cache = new Map<string, AssistantConfig>()
private initialized = false
constructor(deps: AssistantManagerDeps) {
this.deps = deps
this.generalIds = deps.generalIds || ['general_cn', 'general_en', 'general_ja']
this.initBuiltinCache()
}
private initBuiltinCache(): void {
if (!this.deps.builtinRawConfigs) return
for (const { id, content } of this.deps.builtinRawConfigs) {
const config = parseAssistantFile(content, `${id}.md`)
if (config) this.builtinCache.set(config.id, config)
}
}
private getBuiltinConfig(id: string): AssistantConfig | undefined {
return this.builtinCache.get(id)
}
private ensureInitialized(): void {
if (!this.initialized) this.init()
}
// ==================== Init ====================
init(): AssistantInitResult {
const { fs, assistantsDir } = this.deps
fs.ensureDir(assistantsDir)
const generalCreated = this.ensureGeneralAssistants()
this.loadAll()
this.initialized = true
this.deps.logger?.info('AssistantManager', 'Initialized', {
total: this.cache.size,
generalCreated,
})
return { total: this.cache.size, generalCreated }
}
private ensureGeneralAssistants(): boolean {
const { fs, assistantsDir } = this.deps
let anyCreated = false
for (const id of this.generalIds) {
const config = this.getBuiltinConfig(id)
if (!config) continue
const filePath = fs.joinPath(assistantsDir, `${id}.md`)
if (fs.fileExists(filePath)) continue
fs.writeFile(filePath, serializeAssistant({ ...config, builtinId: config.id }))
anyCreated = true
}
return anyCreated
}
private loadAll(): void {
const { fs, assistantsDir } = this.deps
this.cache.clear()
const files = fs.listFiles(assistantsDir, '.md')
for (const file of files) {
try {
const filePath = fs.joinPath(assistantsDir, file)
const content = fs.readFile(filePath)
const config = parseAssistantFile(content, filePath)
if (config) {
this.cache.set(config.id, config)
} else {
this.deps.logger?.warn('AssistantManager', `Failed to parse: ${file}`)
}
} catch (error) {
this.deps.logger?.warn('AssistantManager', `Failed to load: ${file}`, { error: String(error) })
}
}
}
// ==================== Query ====================
getAllAssistants(): AssistantSummary[] {
this.ensureInitialized()
return Array.from(this.cache.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map(toSummary)
}
getAssistantConfig(id: string): AssistantConfig | null {
this.ensureInitialized()
return this.cache.get(id) ?? null
}
hasAssistant(id: string): boolean {
this.ensureInitialized()
return this.cache.has(id)
}
getBuiltinCatalog(): BuiltinAssistantInfo[] {
this.ensureInitialized()
return []
}
isGeneralAssistant(id: string): boolean {
return this.generalIds.includes(id)
}
// ==================== Import ====================
importAssistant(builtinId: string): AssistantSaveResult {
this.ensureInitialized()
const builtinConfig = this.getBuiltinConfig(builtinId)
if (!builtinConfig) return { success: false, error: `Builtin assistant not found: ${builtinId}` }
const existing = this.findByBuiltinId(builtinId)
if (existing) return { success: false, error: `Assistant already imported: ${builtinId}` }
return this.saveToDisk({ ...builtinConfig, builtinId: builtinConfig.id })
}
reimportAssistant(id: string): AssistantSaveResult {
this.ensureInitialized()
const existing = this.cache.get(id)
if (!existing) return { success: false, error: `Assistant not found: ${id}` }
if (!existing.builtinId) return { success: false, error: 'Only imported builtin assistants can be reimported' }
const builtinConfig = this.getBuiltinConfig(existing.builtinId)
if (!builtinConfig) return { success: false, error: `Builtin template not found: ${existing.builtinId}` }
return this.saveToDisk({ ...builtinConfig, id: existing.id, builtinId: existing.builtinId })
}
importAssistantFromMd(rawMd: string): AssistantSaveResult & { id?: string } {
this.ensureInitialized()
const config = parseAssistantFile(rawMd, 'cloud_import.md')
if (!config) return { success: false, error: 'Failed to parse assistant markdown' }
if (this.cache.has(config.id)) return { success: false, error: `Assistant already exists: ${config.id}` }
const result = this.saveToDisk(config)
return { ...result, id: result.success ? config.id : undefined }
}
// ==================== Mutate ====================
updateAssistant(id: string, updates: Partial<AssistantConfig>): AssistantSaveResult {
this.ensureInitialized()
const existing = this.cache.get(id)
if (!existing) return { success: false, error: `Assistant not found: ${id}` }
return this.saveToDisk({ ...existing, ...updates, id })
}
createAssistant(config: Omit<AssistantConfig, 'id'>): AssistantSaveResult & { id?: string } {
this.ensureInitialized()
const id = this.deps.generateId?.() || `custom_${Date.now().toString(36)}`
const newConfig: AssistantConfig = { ...config, id, builtinId: undefined }
const result = this.saveToDisk(newConfig)
return { ...result, id: result.success ? id : undefined }
}
deleteAssistant(id: string): AssistantSaveResult {
this.ensureInitialized()
if (this.generalIds.includes(id)) return { success: false, error: 'Cannot delete the default assistant (general)' }
const existing = this.cache.get(id)
if (!existing) return { success: false, error: `Assistant not found: ${id}` }
try {
const filePath = this.deps.fs.joinPath(this.deps.assistantsDir, `${id}.md`)
if (this.deps.fs.fileExists(filePath)) this.deps.fs.deleteFile(filePath)
this.cache.delete(id)
return { success: true }
} catch (error) {
return { success: false, error: String(error) }
}
}
resetAssistant(id: string): AssistantSaveResult {
this.ensureInitialized()
const existing = this.cache.get(id)
if (!existing?.builtinId) return { success: false, error: 'Only builtin assistants can be reset' }
const builtinConfig = this.getBuiltinConfig(existing.builtinId)
if (!builtinConfig) return { success: false, error: `Builtin config not found: ${existing.builtinId}` }
return this.saveToDisk({ ...builtinConfig, id: existing.id, builtinId: existing.builtinId })
}
// ==================== Internal ====================
private findByBuiltinId(builtinId: string): AssistantConfig | undefined {
return Array.from(this.cache.values()).find((c) => c.builtinId === builtinId)
}
private saveToDisk(config: AssistantConfig): AssistantSaveResult {
try {
const filePath = this.deps.fs.joinPath(this.deps.assistantsDir, `${config.id}.md`)
this.deps.fs.writeFile(filePath, serializeAssistant(config))
this.cache.set(config.id, config)
return { success: true }
} catch (error) {
this.deps.logger?.error('AssistantManager', `Failed to save: ${config.id}`, { error: String(error) })
return { success: false, error: String(error) }
}
}
}
+10
View File
@@ -9,6 +9,16 @@ export { AiLogger, extractErrorInfo, extractErrorStack } from './ai-logger'
export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './types'
export { parseAssistantFile, serializeAssistant } from './assistant-parser'
// Assistant Manager
export { AssistantManager } from './assistant-manager'
export type {
AssistantInitResult,
AssistantSaveResult,
BuiltinAssistantInfo,
AssistantManagerFs,
AssistantManagerDeps,
} from './assistant-manager'
export { parseSkillFile, extractSkillId } from './skill-parser'
export { AIConversationManager } from './conversations'
export type {
+10
View File
@@ -41,6 +41,16 @@ export { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId
export { AIConversationManager } from './ai'
export { countTokens, countMessagesTokens } from './ai'
// Assistant Manager
export { AssistantManager } from './ai'
export type {
AssistantInitResult,
AssistantSaveResult,
BuiltinAssistantInfo,
AssistantManagerFs,
AssistantManagerDeps,
} from './ai'
// Compression
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './ai'
export { checkAndCompress, manualCompress } from './ai'