feat: add SkillManager and activate_skill tool to shared package

Extract SkillManager from Electron to @openchatlab/node-runtime.
Create shared createActivateSkillTool factory.
Integrate skill menu into server agent system prompt and SSE route.
This commit is contained in:
digua committed 2026-05-14 21:34:58 +08:00
1 parent 76b4be1264
commit 58d7da6367
6 files changed
+254 -4

No files matched your search

@@ -0,0 +1,98 @@
/**
* activate_skill 元工具(AI 自选模式专用)
*
* LLM 判断用户问题适合某个技能时调用此工具,获取技能的完整执行指导。
* 平台无关 — 通过 getter 函数获取技能定义。
*/
import type { SkillDef } from './types'
export interface ActivateSkillToolResult {
content: Array<{ type: 'text'; text: string }>
details: Record<string, unknown>
}
export interface ActivateSkillToolOptions {
chatType: 'group' | 'private'
allowedTools?: string[]
coreToolNames?: Set<string>
locale?: string
getSkillConfig: (id: string) => SkillDef | null
}
export interface ActivateSkillTool {
name: string
label: string
description: string
parameters: Record<string, unknown>
execute: (
toolCallId: string,
params: { skill_id: string },
signal?: AbortSignal,
onUpdate?: unknown
) => Promise<ActivateSkillToolResult>
}
export function createActivateSkillTool(options: ActivateSkillToolOptions): ActivateSkillTool {
const { chatType, allowedTools, coreToolNames, getSkillConfig, locale = 'zh-CN' } = options
const isZh = locale.startsWith('zh')
return {
name: 'activate_skill',
label: 'activate_skill',
description: isZh
? '激活一个分析技能,获取该技能的详细执行指导'
: 'Activate an analysis skill and get its detailed execution instructions',
parameters: {
type: 'object',
properties: {
skill_id: {
type: 'string',
description: isZh ? '技能 ID' : 'Skill ID',
},
},
required: ['skill_id'],
},
execute: async (_toolCallId: string, params: { skill_id: string }) => {
const skill: SkillDef | null = getSkillConfig(params.skill_id)
if (!skill) {
return {
content: [{ type: 'text' as const, text: isZh ? '技能不存在' : 'Skill not found' }],
details: { skillId: params.skill_id, found: false },
}
}
if (skill.chatScope !== 'all' && skill.chatScope !== chatType) {
const scopeMsg = isZh
? `该技能仅适用于${skill.chatScope === 'group' ? '群聊' : '私聊'}场景`
: `This skill is only applicable to ${skill.chatScope === 'group' ? 'group chat' : 'private chat'} scenarios`
return {
content: [{ type: 'text' as const, text: scopeMsg }],
details: { skillId: params.skill_id, found: true, applicable: false },
}
}
if (skill.tools.length > 0 && allowedTools && allowedTools.length > 0) {
const missing = skill.tools.filter((t) => !(coreToolNames?.has(t) ?? false) && !allowedTools.includes(t))
if (missing.length > 0) {
const msg = isZh
? `当前助手缺少该技能所需的工具:${missing.join(', ')}`
: `Current assistant lacks tools required by this skill: ${missing.join(', ')}`
return {
content: [{ type: 'text' as const, text: msg }],
details: { skillId: params.skill_id, found: true, applicable: false, missingTools: missing },
}
}
}
const actionPrompt = isZh
? '\n\n[System]: 你已成功加载该技能手册。现在,请立即、自动地开始执行步骤1,调用相关的基础数据工具,不要等待用户的进一步确认!'
: '\n\n[System]: You have successfully loaded this skill manual. Now, immediately start executing step 1 by calling the relevant data tools. Do not wait for further user confirmation!'
return {
content: [{ type: 'text' as const, text: `${skill.prompt}${actionPrompt}` }],
details: { skillId: params.skill_id, found: true, applicable: true },
}
},
}
}
+6
View File
@@ -20,6 +20,12 @@ export type {
// Tokenizer
export { countTokens, countMessagesTokens } from './tokenizer'
// SkillManager
export { SkillManager } from './skill-manager'
export type { SkillManagerLogger } from './skill-manager'
export { createActivateSkillTool } from './activate-skill-tool'
export type { ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './activate-skill-tool'
// Preprocessor
export type {
PreprocessConfig,
@@ -0,0 +1,116 @@
/**
* 技能管理器(平台无关)
*
* 从 aiDataDir/skills/*.md 加载技能定义,
* 提供查询和 AI 自选菜单构建。
*/
import * as fs from 'fs'
import * as path from 'path'
import { parseSkillFile } from './skill-parser'
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
warn(message: string, extra?: Record<string, unknown>): void
}
const defaultLogger: SkillManagerLogger = {
info: () => {},
warn: () => {},
}
export class SkillManager {
private skills = new Map<string, SkillDef>()
private initialized = false
private skillsDir: string
private logger: SkillManagerLogger
constructor(aiDataDir: string, logger?: SkillManagerLogger) {
this.skillsDir = path.join(aiDataDir, SKILLS_DIR_NAME)
this.logger = logger ?? defaultLogger
}
init(): { total: number } {
this.loadAll()
this.initialized = true
this.logger.info(`SkillManager initialized: ${this.skills.size} skills`)
return { total: this.skills.size }
}
getSkillConfig(id: string): SkillDef | null {
this.ensureInitialized()
return this.skills.get(id) ?? null
}
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,
}))
}
/**
* 构建 AI 自选技能菜单文本
* 只包含与当前 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) })
}
}
}
}
+5
View File
@@ -36,6 +36,11 @@ export { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId
export { AIConversationManager } from './ai'
export { countTokens, countMessagesTokens } from './ai'
// SkillManager
export { SkillManager } from './ai'
export type { SkillManagerLogger, ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './ai'
export { createActivateSkillTool } from './ai'
// Preprocessor
export type {
PreprocessConfig,
+12 -3
View File
@@ -37,6 +37,7 @@ export interface RunAgentOptions {
chatType?: 'group' | 'private'
locale?: string
assistantSystemPrompt?: string
skillMenu?: string | null
tools?: import('@mariozechner/pi-agent-core').AgentTool[]
aiDataDir: string
convManager: AIConversationManager
@@ -49,7 +50,8 @@ type SimpleHistoryMessage = { role: 'user' | 'assistant' | 'summary'; content: s
function buildSystemPrompt(
_chatType: 'group' | 'private',
assistantSystemPrompt?: string,
locale: string = 'zh-CN'
locale: string = 'zh-CN',
skillMenu?: string | null
): string {
const now = new Date()
const dateLocale = locale.startsWith('zh') ? 'zh-CN' : 'en-US'
@@ -72,11 +74,17 @@ function buildSystemPrompt(
? '请直接回答用户的问题,不要使用工具除非确实需要。'
: "Answer the user's question directly. Only use tools when truly necessary."
return `${role}
let prompt = `${role}
${datePrefix} ${currentDate}。
${responseNote}`
if (skillMenu) {
prompt += `\n\n${skillMenu}`
}
return prompt
}
function createEmptyPiUsage(): PiUsage {
@@ -119,6 +127,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
chatType = 'group',
locale = 'zh-CN',
assistantSystemPrompt,
skillMenu,
tools = [],
aiDataDir,
convManager,
@@ -136,7 +145,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
const piModel = buildPiModel(llmConfig)
const maxToolRounds = 5
const systemPrompt = buildSystemPrompt(chatType, assistantSystemPrompt, locale)
const systemPrompt = buildSystemPrompt(chatType, assistantSystemPrompt, locale, skillMenu)
const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
const toolsUsed: string[] = []
let toolRounds = 0
+17 -1
View File
@@ -13,7 +13,7 @@ import * as fs from 'fs'
import * as path from 'path'
import type { FastifyInstance } from 'fastify'
import type { DatabaseManager, AIConversationManager } from '@openchatlab/node-runtime'
import { parseAssistantFile, parseSkillFile } from '@openchatlab/node-runtime'
import { parseAssistantFile, parseSkillFile, SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime'
import { BUILTIN_TOOL_CATALOG, BUILTIN_PROVIDERS, BUILTIN_MODELS, getBuiltinModelsByProvider } from '@openchatlab/core'
import type { AssistantSummary, SkillSummary } from '@openchatlab/node-runtime'
import { TOOL_REGISTRY } from '@openchatlab/tools'
@@ -360,6 +360,21 @@ export function registerAiRoutes(
}))
: []
const skillManager = new SkillManager(aiDataDir)
skillManager.init()
const toolNames = agentTools.map((t: { name: string }) => t.name)
const skillMenu = skillManager.getSkillMenu(chatType ?? 'group', toolNames)
if (skillMenu) {
const activateSkillTool = createActivateSkillTool({
chatType: chatType ?? 'group',
allowedTools: toolNames,
locale,
getSkillConfig: (id) => skillManager.getSkillConfig(id),
})
agentTools.push(activateSkillTool as any)
}
const onEvent = (event: AgentStreamEvent) => {
sendSSE(event.type, event)
if (event.type === 'done') {
@@ -382,6 +397,7 @@ export function registerAiRoutes(
chatType,
locale,
assistantSystemPrompt,
skillMenu,
tools: agentTools,
aiDataDir,
convManager,