From f825c0734b7937cff01fa02ea9c7daf59a939dc8 Mon Sep 17 00:00:00 2001 From: digua Date: Fri, 5 Jun 2026 00:04:07 +0800 Subject: [PATCH] refactor(ai): deduplicate skill menu and manager --- package.json | 1 - .../ai/__tests__/builtin-chart-skill.test.ts | 15 ++- .../src/ai/__tests__/skill-manager.test.ts | 62 +++++++++ .../src/ai/__tests__/skill-menu.test.ts | 48 +++++++ packages/node-runtime/src/ai/chart-runtime.ts | 23 ++-- .../node-runtime/src/ai/skill-manager-core.ts | 13 +- packages/node-runtime/src/ai/skill-manager.ts | 121 ++++++++---------- packages/node-runtime/src/ai/skill-menu.ts | 48 +++++++ 8 files changed, 226 insertions(+), 105 deletions(-) create mode 100644 packages/node-runtime/src/ai/__tests__/skill-manager.test.ts create mode 100644 packages/node-runtime/src/ai/__tests__/skill-menu.test.ts create mode 100644 packages/node-runtime/src/ai/skill-menu.ts diff --git a/package.json b/package.json index 6b5292d0..9e905dda 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "type-check": "vue-tsc --noEmit -p tsconfig.web.json", "test:auth": "tsx --test apps/cli/src/http/auth.test.ts src/services/utils/http.test.ts", "test:agent-context": "node --experimental-strip-types --test apps/desktop/main/ai/context/sessionLog.test.mjs", - "test:chart-runtime": "tsx --test packages/core/src/chart/index.test.ts packages/tools/src/definitions/render-chart.test.ts packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts packages/node-runtime/src/ai/__tests__/chart-schema-gate.test.ts apps/cli/src/ai/agent-stream-runner.test.ts apps/cli/src/ai/tool-adapter.test.ts apps/desktop/main/ai/tools/tool-filter.test.ts src/stores/aiChatChartBlocks.test.ts src/stores/skill.test.ts && node --import tsx --test tests/chart-runtime/agent-chart-flow.test.mts", "test:e2e:launcher": "node --test tests/e2e/helpers/app-launcher.test.js", "test:e2e:smoke": "node tests/e2e/run-smoke.js" }, diff --git a/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts b/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts index 3e7eaece..a93cf610 100644 --- a/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts +++ b/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' import { buildSkillMenuWithBuiltinChart, getSkillConfigWithBuiltinChart } from '../builtin-chart-skill' +import { buildSkillMenuText, formatSkillMenuLine } from '../skill-menu' describe('builtin chart skill helpers', () => { it('adds chart_runtime to an empty auto skill menu', () => { @@ -14,13 +15,13 @@ describe('builtin chart skill helpers', () => { }) it('appends chart_runtime to an existing auto skill menu', () => { - const baseMenu = `## 可用技能 -以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时, -请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。 - -- existing: Existing Skill — Existing description - -如果用户的问题不需要使用技能,直接回答即可。` + const baseMenu = buildSkillMenuText([ + formatSkillMenuLine({ + id: 'existing', + name: 'Existing Skill', + description: 'Existing description', + }), + ]) const menu = buildSkillMenuWithBuiltinChart(baseMenu, 'zh-CN') diff --git a/packages/node-runtime/src/ai/__tests__/skill-manager.test.ts b/packages/node-runtime/src/ai/__tests__/skill-manager.test.ts new file mode 100644 index 00000000..4c43d3e5 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/skill-manager.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { SkillManager } from '../skill-manager' + +const SAMPLE_SKILL = `--- +id: legacy_skill +name: Legacy Skill +description: Loaded through the legacy SkillManager API +tags: + - test +chatScope: group +tools: + - keyword_frequency +--- +You are a legacy skill.` + +const tempDirs: string[] = [] + +function createTempAiDataDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-skill-manager-')) + tempDirs.push(dir) + return dir +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('SkillManager legacy adapter', () => { + it('creates the skills directory during initialization', () => { + const aiDataDir = createTempAiDataDir() + const manager = new SkillManager(aiDataDir) + + const result = manager.init() + + assert.equal(result.total, 0) + assert.equal(existsSync(join(aiDataDir, 'skills')), true) + }) + + it('keeps the existing runtime query API while delegating to the core manager', () => { + const aiDataDir = createTempAiDataDir() + const skillsDir = join(aiDataDir, 'skills') + const manager = new SkillManager(aiDataDir) + manager.init() + writeFileSync(join(skillsDir, 'legacy_skill.md'), SAMPLE_SKILL, 'utf-8') + manager.init() + + const menu = manager.getSkillMenu('group', ['keyword_frequency']) + + assert.ok(menu) + assert.match(menu, /legacy_skill/) + assert.equal(manager.getSkillConfig('legacy_skill')?.name, 'Legacy Skill') + assert.equal(manager.getAllSkills().length, 1) + assert.equal(manager.getSkillMenu('private', ['keyword_frequency']), null) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/skill-menu.test.ts b/packages/node-runtime/src/ai/__tests__/skill-menu.test.ts new file mode 100644 index 00000000..9e0ee1d0 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/skill-menu.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { appendSkillMenuLines, buildSkillMenuText, formatSkillMenuLine } from '../skill-menu' + +describe('skill menu builder', () => { + it('returns null when there are no visible skill items', () => { + assert.equal(buildSkillMenuText([]), null) + }) + + it('builds the shared auto skill menu template', () => { + const menu = buildSkillMenuText([ + formatSkillMenuLine({ + id: 'summary', + name: 'Summary', + description: 'Summarize the current chat', + }), + ]) + + assert.ok(menu) + assert.match(menu, /^## 可用技能/) + assert.match(menu, /activate_skill 工具激活/) + assert.match(menu, /- summary: Summary — Summarize the current chat/) + assert.match(menu, /如果用户的问题不需要使用技能,直接回答即可。$/) + }) + + it('appends generated skill lines before the shared closing guidance', () => { + const baseMenu = buildSkillMenuText([ + formatSkillMenuLine({ + id: 'existing', + name: 'Existing Skill', + description: 'Existing description', + }), + ]) + + const menu = appendSkillMenuLines(baseMenu, [ + formatSkillMenuLine({ + id: 'chart_runtime', + name: '绘图助手', + description: '按本轮问题生成灵活的聊天数据图表', + }), + ]) + + assert.ok(menu) + assert.ok(menu.indexOf('existing') < menu.indexOf('chart_runtime')) + assert.ok(menu.indexOf('chart_runtime') < menu.indexOf('如果用户的问题不需要使用技能')) + }) +}) diff --git a/packages/node-runtime/src/ai/chart-runtime.ts b/packages/node-runtime/src/ai/chart-runtime.ts index b51ee2c6..fc4afc8a 100644 --- a/packages/node-runtime/src/ai/chart-runtime.ts +++ b/packages/node-runtime/src/ai/chart-runtime.ts @@ -1,4 +1,5 @@ import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' +import { appendSkillMenuLines, formatSkillMenuLine } from './skill-menu' import type { SkillDef } from './types' export { CHART_CAPABILITY_SKILL_ID } @@ -81,27 +82,19 @@ function getChartMenuLine(locale: string): string { const guidance = isZh ? '用户明确要求图表、画图、占比、趋势、分布、饼图、柱状图、折线图或热力图时优先激活;不要输出 Python/JS 绘图代码' : 'Activate first when the user explicitly asks for charts, visualization, ratios, trends, distributions, pie, bar, line, or heatmap charts; do not output Python/JS chart code' - return `- ${skill.id}: ${skill.name} — ${skill.description}. ${guidance}` + return formatSkillMenuLine({ + id: skill.id, + name: skill.name, + description: skill.description, + guidance, + }) } export function buildSkillMenuWithBuiltinChart(baseMenu: string | null | undefined, locale: string = 'zh-CN'): string { if (baseMenu?.includes(CHART_CAPABILITY_SKILL_ID)) return baseMenu const chartLine = getChartMenuLine(locale) - if (!baseMenu) { - return `## 可用技能 -以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时, -请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。 - -${chartLine} - -如果用户的问题不需要使用技能,直接回答即可。` - } - - const closing = '\n\n如果用户的问题不需要使用技能,直接回答即可。' - if (!baseMenu.includes(closing)) return `${baseMenu}\n${chartLine}` - - return baseMenu.replace(closing, `\n${chartLine}${closing}`) + return appendSkillMenuLines(baseMenu, [chartLine]) ?? '' } export function getSkillConfigWithBuiltinChart( diff --git a/packages/node-runtime/src/ai/skill-manager-core.ts b/packages/node-runtime/src/ai/skill-manager-core.ts index 9126976c..db60e938 100644 --- a/packages/node-runtime/src/ai/skill-manager-core.ts +++ b/packages/node-runtime/src/ai/skill-manager-core.ts @@ -7,6 +7,7 @@ */ import { parseSkillFile } from './skill-parser' +import { buildSkillMenuText, formatSkillMenuLine, MAX_SKILL_MENU_ITEMS } from './skill-menu' import type { SkillDef, SkillSummary } from './types' // ==================== Result types ==================== @@ -90,8 +91,6 @@ function simpleHash(content: string): string { // ==================== Manager ==================== -const MAX_SKILL_MENU_ITEMS = 15 - export class SkillManagerCore { private deps: SkillManagerCoreDeps private builtinDefs = new Map() @@ -301,15 +300,7 @@ export class SkillManagerCore { if (compatible.length === 0) return null const items = compatible.slice(0, MAX_SKILL_MENU_ITEMS) - const lines = items.map((s) => `- ${s.id}: ${s.name} — ${s.description}`) - - return `## 可用技能 -以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时, -请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。 - -${lines.join('\n')} - -如果用户的问题不需要使用技能,直接回答即可。` + return buildSkillMenuText(items.map(formatSkillMenuLine)) } // ==================== Internal ==================== diff --git a/packages/node-runtime/src/ai/skill-manager.ts b/packages/node-runtime/src/ai/skill-manager.ts index eed053c3..383e5b60 100644 --- a/packages/node-runtime/src/ai/skill-manager.ts +++ b/packages/node-runtime/src/ai/skill-manager.ts @@ -7,11 +7,10 @@ import * as fs from 'fs' import * as path from 'path' -import { parseSkillFile } from './skill-parser' +import { SkillManagerCore, type SkillInitResult, type SkillManagerFs } from './skill-manager-core' import type { SkillDef, SkillSummary } from './types' const SKILLS_DIR_NAME = 'skills' -const MAX_SKILL_MENU_ITEMS = 15 export interface SkillManagerLogger { info(message: string, extra?: Record): void @@ -23,40 +22,61 @@ const defaultLogger: SkillManagerLogger = { warn: () => {}, } +const nodeFs: SkillManagerFs = { + ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }) + }, + listFiles(dir, ext) { + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir).filter((file) => file.endsWith(ext)) + }, + readFile(filePath) { + return fs.readFileSync(filePath, 'utf-8') + }, + writeFile(filePath, content) { + fs.writeFileSync(filePath, content, 'utf-8') + }, + deleteFile(filePath) { + fs.unlinkSync(filePath) + }, + fileExists(filePath) { + return fs.existsSync(filePath) + }, + joinPath(...parts) { + return path.join(...parts) + }, +} + export class SkillManager { - private skills = new Map() - private initialized = false - private skillsDir: string + private core: SkillManagerCore private logger: SkillManagerLogger constructor(aiDataDir: string, logger?: SkillManagerLogger) { - this.skillsDir = path.join(aiDataDir, SKILLS_DIR_NAME) this.logger = logger ?? defaultLogger + this.core = new SkillManagerCore({ + fs: nodeFs, + skillsDir: path.join(aiDataDir, SKILLS_DIR_NAME), + builtinRawSkills: [], + logger: { + info: () => {}, + warn: (_category, message, data) => this.logger.warn(message, toLoggerExtra(data)), + error: (_category, message, data) => this.logger.warn(message, toLoggerExtra(data)), + }, + }) } - init(): { total: number } { - this.loadAll() - this.initialized = true - this.logger.info(`SkillManager initialized: ${this.skills.size} skills`) - return { total: this.skills.size } + init(): SkillInitResult { + const result = this.core.init() + this.logger.info(`SkillManager initialized: ${result.total} skills`) + return result } getSkillConfig(id: string): SkillDef | null { - this.ensureInitialized() - return this.skills.get(id) ?? null + return this.core.getSkillConfig(id) } getAllSkills(): SkillSummary[] { - this.ensureInitialized() - return Array.from(this.skills.values()).map((def) => ({ - id: def.id, - name: def.name, - description: def.description, - tags: def.tags, - chatScope: def.chatScope, - tools: def.tools, - builtinId: def.builtinId, - })) + return this.core.getAllSkills() } /** @@ -64,53 +84,12 @@ export class SkillManager { * 只包含与当前 chatType + 助手工具权限兼容的技能 */ getSkillMenu(chatType: 'group' | 'private', allowedTools?: string[]): string | null { - this.ensureInitialized() - - const compatible = Array.from(this.skills.values()).filter((skill) => { - if (skill.chatScope !== 'all' && skill.chatScope !== chatType) return false - if (skill.tools.length > 0 && allowedTools && allowedTools.length > 0) { - const allCovered = skill.tools.every((t) => allowedTools.includes(t)) - if (!allCovered) return false - } - return true - }) - - if (compatible.length === 0) return null - - const items = compatible.slice(0, MAX_SKILL_MENU_ITEMS) - const lines = items.map((s) => `- ${s.id}: ${s.name} — ${s.description}`) - - return `## 可用技能 -以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时, -请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。 - -${lines.join('\n')} - -如果用户的问题不需要使用技能,直接回答即可。` - } - - private ensureInitialized(): void { - if (!this.initialized) { - this.init() - } - } - - private loadAll(): void { - this.skills.clear() - if (!fs.existsSync(this.skillsDir)) return - - const files = fs.readdirSync(this.skillsDir).filter((f) => f.endsWith('.md')) - for (const file of files) { - try { - const filePath = path.join(this.skillsDir, file) - const content = fs.readFileSync(filePath, 'utf-8') - const def = parseSkillFile(content, filePath) - if (def) { - this.skills.set(def.id, def) - } - } catch (error) { - this.logger.warn(`Failed to load skill: ${file}`, { error: String(error) }) - } - } + return this.core.getSkillMenu(chatType, allowedTools) } } + +function toLoggerExtra(data: unknown): Record | undefined { + if (data === undefined) return undefined + if (data && typeof data === 'object' && !Array.isArray(data)) return data as Record + return { data } +} diff --git a/packages/node-runtime/src/ai/skill-menu.ts b/packages/node-runtime/src/ai/skill-menu.ts new file mode 100644 index 00000000..5440db0b --- /dev/null +++ b/packages/node-runtime/src/ai/skill-menu.ts @@ -0,0 +1,48 @@ +export const MAX_SKILL_MENU_ITEMS = 15 + +const SKILL_MENU_HEADER = `## 可用技能 +以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时, +请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。` + +const SKILL_MENU_CLOSING = '如果用户的问题不需要使用技能,直接回答即可。' +const SKILL_MENU_CLOSING_BLOCK = `\n\n${SKILL_MENU_CLOSING}` + +export interface SkillMenuItem { + id: string + name: string + description: string + guidance?: string +} + +export function formatSkillMenuLine(item: SkillMenuItem): string { + const guidanceSuffix = item.guidance ? `. ${item.guidance}` : '' + return `- ${item.id}: ${item.name} — ${item.description}${guidanceSuffix}` +} + +export function buildSkillMenuText(lines: readonly string[]): string | null { + const visibleLines = normalizeSkillMenuLines(lines) + if (visibleLines.length === 0) return null + + return `${SKILL_MENU_HEADER} + +${visibleLines.join('\n')} + +${SKILL_MENU_CLOSING}` +} + +export function appendSkillMenuLines(baseMenu: string | null | undefined, lines: readonly string[]): string | null { + const visibleLines = normalizeSkillMenuLines(lines) + if (visibleLines.length === 0) return baseMenu ?? null + if (!baseMenu) return buildSkillMenuText(visibleLines) + + const insertion = `\n${visibleLines.join('\n')}` + if (!baseMenu.includes(SKILL_MENU_CLOSING_BLOCK)) { + return `${baseMenu}${insertion}` + } + + return baseMenu.replace(SKILL_MENU_CLOSING_BLOCK, `${insertion}${SKILL_MENU_CLOSING_BLOCK}`) +} + +function normalizeSkillMenuLines(lines: readonly string[]): string[] { + return lines.map((line) => line.trim()).filter(Boolean) +}