mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor(ai): deduplicate skill menu and manager
This commit is contained in:
1 parent
953471a514
commit
f825c0734b
8 files changed
+226
-105
No files matched your search
@@ -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('如果用户的问题不需要使用技能'))
|
||||
})
|
||||
})
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user