refactor(ai): deduplicate skill menu and manager

This commit is contained in:
digua committed 2026-06-05 11:12:42 +08:00
1 parent 953471a514
commit f825c0734b
8 files changed
+226 -105

No files matched your search

-1
View File
@@ -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"
},
@@ -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')
@@ -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)
})
})
@@ -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('如果用户的问题不需要使用技能'))
})
})
+8 -15
View File
@@ -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(
@@ -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<string, SkillDef>()
@@ -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 ====================
+50 -71
View File
@@ -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<string, unknown>): 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<string, SkillDef>()
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<string, unknown> | undefined {
if (data === undefined) return undefined
if (data && typeof data === 'object' && !Array.isArray(data)) return data as Record<string, unknown>
return { data }
}
@@ -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)
}