mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 18:34:51 +08:00
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:
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 },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user