From 1eb5e921684bfc89a178137227e5eaf3779c384b Mon Sep 17 00:00:00 2001 From: gene9831 Date: Sat, 9 May 2026 18:00:17 +0800 Subject: [PATCH 1/9] feat(kit): add message skill plugin and loaders - add skillPlugin for active skill instructions, tools, and file runtime tools - add SkillLoader with fs and browser file adapters - extend toolPlugin with runtime tools and provider-based tool collection - detect duplicate function tool names before request - add skill and tool plugin tests with skill fixtures --- packages/kit/package.json | 3 +- packages/kit/src/message/core/engine.ts | 3 +- packages/kit/src/message/plugins/index.ts | 11 + .../kit/src/message/plugins/skillPlugin.ts | 438 ++++++++++++++++++ .../kit/src/message/plugins/toolPlugin.ts | 107 ++++- .../src/message/skills/browserSkillLoader.ts | 102 ++++ .../kit/src/message/skills/fsSkillLoader.ts | 74 +++ .../kit/src/message/skills/skillLoader.ts | 248 ++++++++++ packages/kit/src/message/skills/types.ts | 48 ++ packages/kit/src/message/skills/utils.ts | 26 ++ .../skills/vue-best-practices/SKILL.md | 154 ++++++ .../animation-class-based-technique.md | 254 ++++++++++ .../animation-state-driven-technique.md | 291 ++++++++++++ .../references/component-async.md | 97 ++++ .../references/component-data-flow.md | 307 ++++++++++++ .../references/component-fallthrough-attrs.md | 174 +++++++ .../references/component-keep-alive.md | 137 ++++++ .../references/component-slots.md | 216 +++++++++ .../references/component-suspense.md | 228 +++++++++ .../references/component-teleport.md | 108 +++++ .../references/component-transition-group.md | 128 +++++ .../references/component-transition.md | 125 +++++ .../references/composables.md | 290 ++++++++++++ .../references/directives.md | 162 +++++++ ...rf-avoid-component-abstraction-in-lists.md | 159 +++++++ .../perf-v-once-v-memo-directives.md | 182 ++++++++ .../references/perf-virtualize-large-lists.md | 187 ++++++++ .../vue-best-practices/references/plugins.md | 166 +++++++ .../references/reactivity.md | 344 ++++++++++++++ .../references/render-functions.md | 201 ++++++++ .../vue-best-practices/references/sfc.md | 310 +++++++++++++ .../references/state-management.md | 135 ++++++ .../references/updated-hook-performance.md | 187 ++++++++ .../test/fixtures/skills/weather/SKILL.md | 129 ++++++ .../src/message/test/mockResponseProvider.ts | 2 +- .../kit/src/message/test/skillLoader.test.ts | 89 ++++ .../kit/src/message/test/skillPlugin.test.ts | 336 ++++++++++++++ .../kit/src/message/test/toolPlugin.test.ts | 248 ++++++++++ packages/kit/src/message/types.ts | 10 +- packages/kit/src/message/utils.ts | 2 +- .../src/vue/message/mockResponseProvider.ts | 2 +- .../kit/src/vue/message/plugins/toolPlugin.ts | 7 +- 42 files changed, 6410 insertions(+), 17 deletions(-) create mode 100644 packages/kit/src/message/plugins/skillPlugin.ts create mode 100644 packages/kit/src/message/skills/browserSkillLoader.ts create mode 100644 packages/kit/src/message/skills/fsSkillLoader.ts create mode 100644 packages/kit/src/message/skills/skillLoader.ts create mode 100644 packages/kit/src/message/skills/types.ts create mode 100644 packages/kit/src/message/skills/utils.ts create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/SKILL.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-class-based-technique.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-state-driven-technique.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-async.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-data-flow.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-fallthrough-attrs.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-keep-alive.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-slots.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-suspense.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-teleport.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition-group.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/composables.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/directives.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-virtualize-large-lists.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/plugins.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/reactivity.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/render-functions.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/sfc.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/state-management.md create mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/updated-hook-performance.md create mode 100644 packages/kit/src/message/test/fixtures/skills/weather/SKILL.md create mode 100644 packages/kit/src/message/test/skillLoader.test.ts create mode 100644 packages/kit/src/message/test/skillPlugin.test.ts create mode 100644 packages/kit/src/message/test/toolPlugin.test.ts diff --git a/packages/kit/package.json b/packages/kit/package.json index 2c4ba37a8..41ff53720 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -69,6 +69,7 @@ "vue": ">=3.0.0" }, "dependencies": { - "idb": "^8.0.3" + "idb": "^8.0.3", + "yaml": "^2.8.3" } } diff --git a/packages/kit/src/message/core/engine.ts b/packages/kit/src/message/core/engine.ts index fcb467014..362d27c38 100644 --- a/packages/kit/src/message/core/engine.ts +++ b/packages/kit/src/message/core/engine.ts @@ -1,4 +1,4 @@ -import { ChatCompletion, ChatCompletionChunk } from 'openai/resources/index' +import { ChatCompletion, ChatCompletionChunk } from 'openai/resources' import { lengthPlugin, thinkingPlugin } from '../plugins' import { BasePluginContext, @@ -156,6 +156,7 @@ export const createMessageEngine = ( mutate, abortSignal, currentTurn: runtime.currentTurn, + plugins, customContext: runtime.customContext, setRequestState, setCustomContext, diff --git a/packages/kit/src/message/plugins/index.ts b/packages/kit/src/message/plugins/index.ts index b7efeef2c..77b42113e 100644 --- a/packages/kit/src/message/plugins/index.ts +++ b/packages/kit/src/message/plugins/index.ts @@ -1,3 +1,14 @@ +export type { + BaseSkillFile, + BinarySkillFile, + SkillFile, + SkillFileKind, + SkillFileResource, + TextSkillFile, +} from '../skills/types' export { lengthPlugin } from './lengthPlugin' +export { skillPlugin } from './skillPlugin' +export type { SkillDefinition } from './skillPlugin' export { thinkingPlugin } from './thinkingPlugin' export { toolPlugin } from './toolPlugin' +export type { RuntimeTool, ToolCallContext, ToolProvider, ToolProviderItem } from './toolPlugin' diff --git a/packages/kit/src/message/plugins/skillPlugin.ts b/packages/kit/src/message/plugins/skillPlugin.ts new file mode 100644 index 000000000..0d5b964ea --- /dev/null +++ b/packages/kit/src/message/plugins/skillPlugin.ts @@ -0,0 +1,438 @@ +import type { ChatCompletionFunctionTool } from 'openai/resources' +import type { MaybePromise } from '../../types' +import type { BasePluginContext, BeforeRequestContext, MessageEnginePlugin } from '../types' +import type { SkillFileResource } from '../skills/types' +import type { RuntimeTool, ToolProvider } from './toolPlugin' + +/** + * TODO(skillPlugin): + * 1. 为 read_skill_file 增加长度限制和可配置截断策略,避免超长文件一次性进入上下文。 + * Add size limits and configurable truncation for read_skill_file to avoid injecting + * oversized files into context in one call. + * 2. 实现 skill files 消费策略:支持文件选择、去重、上下文格式化与长度控制。 + * Implement skill files consumption strategy: file selection, deduplication, + * context formatting, and length control. + * 3. 增加 Vue 层状态暴露封装:将当前 active skills 同步给 UI 展示或调试面板。 + * Expose Vue-side state for active skills so UI/debug panels can display them. + * 4. 补充测试:覆盖 getActiveSkills、customContext 写入、compileSkills 调用、 + * 重名 skill 去重、未知 skill 忽略以及和其他插件的 hook 顺序。 + * Add tests for getActiveSkills, customContext state, compileSkills, + * duplicate skill deduplication, unknown skill ignoring, and plugin hook ordering. + */ + +/** + * Skill 解析上下文。 + * + * 用于由业务侧通过统一入口决定当前 turn 应激活哪些 skills。 + * 具体激活来源可以是 UI 勾选、规则匹配、模型选择、后端策略或 skills 管理工具。 + */ +export interface SkillResolveContext extends BasePluginContext { + /** + * 当前插件持有的全部 skill 定义。 + */ + skills: SkillDefinition[] +} + +/** + * 单个 Skill 的运行时上下文。 + * + * 用于动态生成 instructions、tools,或在回调中读取当前已激活的 skill 列表。 + */ +export interface SkillRuntimeContext extends BasePluginContext { + /** + * 当前正在处理的 skill。 + */ + skill: SkillDefinition + /** + * 当前 turn 已激活的全部 skills。 + */ + activeSkills: SkillDefinition[] +} + +/** + * Skill 定义。 + * + * Skill 是一组提示词、工具和文件上下文的能力包。它最终通常会被编译为: + * - system/developer prompt + * - requestBody.tools + * - 可按需读取的文件上下文 + * + * 当前插件先提供类型和生命周期框架,具体编译策略后续再实现。 + */ +export interface SkillDefinition { + /** + * Skill 唯一名称。用于激活、去重、调试和持久化。 + */ + name: string + /** + * Skill 能力描述。可用于自动匹配,也可作为模型选择 skill 时的说明。 + */ + description: string + /** + * 注入给模型的 skill 指令。 + * + * 后续可在 onBeforeRequest 中编译为 system/developer message。 + */ + instructions?: string | ((context: SkillRuntimeContext) => MaybePromise) + /** + * Skill 暴露的工具列表。 + * + * 后续可在 onBeforeRequest 中合并到 requestBody.tools,并复用 toolPlugin 执行 tool_calls。 + */ + tools?: ChatCompletionFunctionTool[] | ((context: SkillRuntimeContext) => MaybePromise) + /** + * Skill 目录下除入口文件和工具配置外的文件数据。 + * + * files 表示 skill 自带的静态文件全集;本轮选择哪些文件应由编译策略决定。 + */ + files?: SkillFileResource[] + /** + * 业务侧自定义元数据。 + */ + metadata?: Record +} + +/** + * 本轮 skill 解析结果。 + * + * 该对象会写入 customContext.__tiny_robot_skill,供后续插件或业务回调读取。 + */ +export interface SkillPluginState { + /** + * 当前 turn 激活的 skill 定义。 + */ + activeSkills: SkillDefinition[] + /** + * 当前 turn 激活的 skill 名称。便于展示、日志和序列化。 + */ + activeSkillNames: string[] + /** + * 当前 turn 的运行时工具。 + * + * 由 toolPlugin.getTools(context) 读取并统一注入、执行。 + */ + runtimeTools?: RuntimeTool[] +} + +/** + * Skill 引用。 + * + * 可以传入 skill name,也可以直接传入 SkillDefinition。 + * 传入 name 时会从 options.skills 中查找;查不到的名称会被忽略。 + */ +type SkillRef = string | SkillDefinition + +/** + * skillPlugin 配置项。 + */ +export type SkillPluginOptions = MessageEnginePlugin & { + /** + * 可用 skill 列表。 + */ + skills: SkillDefinition[] + /** + * 获取当前 turn 激活的 skills。 + * + * 这是 skillPlugin 唯一的激活入口。UI 选择、规则匹配、模型选择、后端策略 + * 或独立的 skills 管理工具都应在外部收敛为这个结果,插件不关心激活来源。 + */ + getActiveSkills?: (context: SkillResolveContext) => MaybePromise + /** + * Skill 解析完成后触发。 + * + * 可用于记录日志、同步 UI 状态或调试激活结果。 + */ + onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise + /** + * 单个 skill 被激活时触发。 + */ + onSkillActivated?: ( + skill: SkillDefinition, + context: BasePluginContext & { activeSkills: SkillDefinition[] }, + ) => MaybePromise + /** + * 请求前的 skill 编译钩子。 + * + * 当前插件暂不内置 prompt/tools 注入策略,业务侧可以先在这里实验编译逻辑。 + * 后续稳定后再沉淀为内置实现。 + */ + compileSkills?: (state: SkillPluginState, context: BeforeRequestContext) => MaybePromise +} + +const uniqueSkills = (skills: SkillDefinition[]) => { + const result: SkillDefinition[] = [] + const names = new Set() + + for (const skill of skills) { + if (names.has(skill.name)) { + continue + } + + names.add(skill.name) + result.push(skill) + } + + return result +} + +const skillPluginContextKey = '__tiny_robot_skill' + +const skillFileToolNames = { + listSkillFiles: 'list_skill_files', + readSkillFile: 'read_skill_file', +} as const + +const skillFileTools: Array = [ + { + type: 'function', + function: { + name: skillFileToolNames.listSkillFiles, + description: 'List files available from the active skills.', + parameters: { + type: 'object', + properties: { + skillName: { + type: 'string', + description: 'Optional active skill name. When omitted, files from all active skills are listed.', + }, + }, + additionalProperties: false, + }, + }, + }, + { + type: 'function', + function: { + name: skillFileToolNames.readSkillFile, + description: 'Read a file from an active skill by skill name and relative path.', + parameters: { + type: 'object', + properties: { + skillName: { + type: 'string', + description: 'Active skill name that owns the file.', + }, + path: { + type: 'string', + description: 'File path relative to the skill root.', + }, + }, + required: ['skillName', 'path'], + additionalProperties: false, + }, + }, + }, +] + +const hasActiveSkillFiles = (skills: SkillDefinition[]) => skills.some((skill) => Boolean(skill.files?.length)) + +const getSkillFileSummary = (skillName: string, file: SkillFileResource) => ({ + skillName, + id: file.id, + path: file.path, + kind: file.kind, + mimeType: file.mimeType, + size: file.size, + lastModified: file.lastModified, +}) + +const parseSkillToolArguments = (toolCall: Parameters[0]): Record => { + const rawArguments = toolCall.function.arguments + + if (!rawArguments) { + return {} + } + + try { + const parsed = JSON.parse(rawArguments) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } +} + +const createSkillFileRuntimeTools = (activeSkills: SkillDefinition[]): RuntimeTool[] => { + if (!hasActiveSkillFiles(activeSkills)) { + return [] + } + + const findSkill = (skillName?: unknown) => { + if (typeof skillName !== 'string' || !skillName) { + return undefined + } + + return activeSkills.find((skill) => skill.name === skillName) + } + + return [ + { + tool: skillFileTools[0], + handler: (toolCall) => { + const toolArguments = parseSkillToolArguments(toolCall) + const skill = findSkill(toolArguments.skillName) + const skills = skill ? [skill] : activeSkills + + return { + files: skills.flatMap((activeSkill) => + (activeSkill.files ?? []).map((file) => getSkillFileSummary(activeSkill.name, file)), + ), + } + }, + }, + { + tool: skillFileTools[1], + handler: (toolCall) => { + const toolArguments = parseSkillToolArguments(toolCall) + const skill = findSkill(toolArguments.skillName) + const path = typeof toolArguments.path === 'string' ? toolArguments.path : undefined + + if (!skill) { + return { error: 'skill_not_found' } + } + + if (!path) { + return { error: 'file_path_required', skillName: skill.name } + } + + const file = skill.files?.find((skillFile) => skillFile.path === path) + if (!file) { + return { error: 'file_not_found', skillName: skill.name, path } + } + + if (file.kind === 'binary') { + return { + error: 'binary_file_not_readable', + file: getSkillFileSummary(skill.name, file), + } + } + + return { + file: getSkillFileSummary(skill.name, file), + content: file.content, + } + }, + }, + ] +} + +const resolveSkillInstructions = async (skill: SkillDefinition, context: SkillRuntimeContext) => { + if (!skill.instructions) { + return '' + } + + const instructions = typeof skill.instructions === 'function' ? await skill.instructions(context) : skill.instructions + return instructions.trim() +} + +const resolveSkillTools = async (skill: SkillDefinition, context: SkillRuntimeContext) => { + if (!skill.tools) { + return [] + } + + return typeof skill.tools === 'function' ? await skill.tools(context) : skill.tools +} + +const compileActiveSkills = async (state: SkillPluginState, context: BeforeRequestContext) => { + const instructions: string[] = [] + + for (const skill of state.activeSkills) { + const runtimeContext: SkillRuntimeContext = { + ...context, + skill, + activeSkills: state.activeSkills, + } + + const instruction = await resolveSkillInstructions(skill, runtimeContext) + if (instruction) { + instructions.push(`## ${skill.name}\n\n${instruction}`) + } + } + + if (instructions.length > 0) { + context.requestBody.messages = [ + { + role: 'system', + content: ['Apply these skill instructions when generating the response.', ...instructions].join('\n\n'), + }, + ...context.requestBody.messages, + ] + } +} + +export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin => { + const { skills, getActiveSkills, onSkillsResolved, onSkillActivated, compileSkills, ...restOptions } = options + + const skillMap = new Map(skills.map((skill) => [skill.name, skill])) + + const resolveSkillRefs = (skillRefs: SkillRef[] = []) => { + const activeSkills = skillRefs + .map((skill) => { + if (typeof skill === 'string') { + return skillMap.get(skill) + } + + return skill + }) + .filter((skill): skill is SkillDefinition => Boolean(skill)) + + return uniqueSkills(activeSkills) + } + + const resolveActiveSkills = async (context: BasePluginContext) => { + const resolveContext: SkillResolveContext = { ...context, skills } + return resolveSkillRefs(await getActiveSkills?.(resolveContext)) + } + + const provideSkillTools = async (context: BasePluginContext) => { + const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined + if (!state) { + return [] + } + + const skillTools: ChatCompletionFunctionTool[] = [] + for (const skill of state.activeSkills) { + const runtimeContext: SkillRuntimeContext = { + ...context, + skill, + activeSkills: state.activeSkills, + } + skillTools.push(...(await resolveSkillTools(skill, runtimeContext))) + } + + return [...(state.runtimeTools ?? []), ...skillTools] + } + + return { + name: 'skill', + ...restOptions, + provideTools: provideSkillTools, + onTurnStart: async (context) => { + const activeSkills = await resolveActiveSkills(context) + const runtimeTools = createSkillFileRuntimeTools(activeSkills) + const state: SkillPluginState = { + activeSkills, + activeSkillNames: activeSkills.map((skill) => skill.name), + runtimeTools: runtimeTools.length ? runtimeTools : undefined, + } + + context.setCustomContext({ [skillPluginContextKey]: state }) + + for (const skill of activeSkills) { + await onSkillActivated?.(skill, { ...context, activeSkills }) + } + + await onSkillsResolved?.(state, context) + return restOptions.onTurnStart?.(context) + }, + onBeforeRequest: async (context) => { + const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined + + if (state) { + await compileActiveSkills(state, context) + await compileSkills?.(state, context) + } + + return restOptions.onBeforeRequest?.(context) + }, + } as MessageEnginePlugin & ToolProvider +} diff --git a/packages/kit/src/message/plugins/toolPlugin.ts b/packages/kit/src/message/plugins/toolPlugin.ts index 0748c1715..478e75822 100644 --- a/packages/kit/src/message/plugins/toolPlugin.ts +++ b/packages/kit/src/message/plugins/toolPlugin.ts @@ -1,5 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources/index' +import { + ChatCompletionFunctionTool, + ChatCompletionMessageFunctionToolCall, + ChatCompletionMessageToolCall, +} from 'openai/resources' +import type { MaybePromise } from '../../types' import type { BasePluginContext, ChatMessage, MessageEnginePlugin, MutateMessageStateFn } from '../types' import { combineDeltaData, normalizeToAsyncGenerator } from '../utils' @@ -8,11 +13,25 @@ type AssistantMessageWithState = ChatMessage< { toolCall?: Record> } > -type ToolCallContext = BasePluginContext & { +export type ToolCallContext = BasePluginContext & { assistantMessage: AssistantMessageWithState toolMessage: ChatMessage } +type ToolCallResult = string | Record +type ToolCallReturn = ToolCallResult | Promise | AsyncGenerator + +export interface RuntimeTool { + tool: ChatCompletionFunctionTool + handler: (toolCall: ChatCompletionMessageFunctionToolCall, context: ToolCallContext) => ToolCallReturn +} + +export type ToolProviderItem = ChatCompletionFunctionTool | RuntimeTool + +export interface ToolProvider { + provideTools: (context: BasePluginContext) => MaybePromise +} + /** * 补全缺失的工具消息 * 遍历所有 messages,找到所有 role 为 assistant 并且 tool_calls 数组不为空的 message。 @@ -99,9 +118,9 @@ function fillMissingToolMessages({ export const toolPlugin = ( options: MessageEnginePlugin & { /** - * 获取工具列表的函数。会在请求大模型前调用。 + * 获取本轮可用工具。可以返回普通 tool schema,也可以返回带执行函数的 runtime tool。 */ - getTools: () => Promise + getTools: (context: BasePluginContext) => MaybePromise /** * 在处理包含 tool_calls 的响应前调用。 */ @@ -115,7 +134,7 @@ export const toolPlugin = ( callTool: ( toolCall: ChatCompletionMessageToolCall, context: ToolCallContext, - ) => Promise> | AsyncGenerator> + ) => Promise | AsyncGenerator /** * 工具调用开始时的回调函数。 * 触发时机:工具消息已创建并追加后,调用 callTool 之前触发。 @@ -197,6 +216,70 @@ export const toolPlugin = ( onToolCallEnd?.(...args) } + const isFunctionToolCall = ( + toolCall: ChatCompletionMessageToolCall, + ): toolCall is ChatCompletionMessageFunctionToolCall => { + return toolCall.type === 'function' && 'function' in toolCall + } + + const isRuntimeTool = (tool: ToolProviderItem): tool is RuntimeTool => { + return Boolean(tool && typeof tool === 'object' && 'tool' in tool && 'handler' in tool) + } + + const getToolProvider = (plugin: MessageEnginePlugin): ToolProvider | undefined => { + const toolProvider = plugin as Partial + return typeof toolProvider.provideTools === 'function' ? (toolProvider as ToolProvider) : undefined + } + + const isPluginDisabled = (plugin: MessageEnginePlugin, context: BasePluginContext) => { + return typeof plugin.disabled === 'function' ? plugin.disabled(context) : Boolean(plugin.disabled) + } + + const resolveTools = async (context: BasePluginContext, existingTools: ChatCompletionFunctionTool[] = []) => { + const providedToolItems: ToolProviderItem[] = [] + + for (const plugin of context.plugins) { + const toolProvider = getToolProvider(plugin) + if (!isPluginDisabled(plugin, context) && toolProvider) { + providedToolItems.push(...(await toolProvider.provideTools(context))) + } + } + + const toolItems = [...providedToolItems, ...(await getTools(context))] + const tools: ChatCompletionFunctionTool[] = [] + const runtimeToolMap = new Map() + const seenToolNames = new Set() + + const trackToolName = (tool: ChatCompletionFunctionTool) => { + const toolName = tool.function.name + + if (seenToolNames.has(toolName)) { + throw new Error( + `Duplicate tool name "${toolName}" detected. Tool names must be unique because tool calls are routed by function.name.`, + ) + } + + seenToolNames.add(toolName) + } + + existingTools.forEach(trackToolName) + + for (const toolItem of toolItems) { + const tool = isRuntimeTool(toolItem) ? toolItem.tool : toolItem + + trackToolName(tool) + + if (isRuntimeTool(toolItem)) { + tools.push(toolItem.tool) + runtimeToolMap.set(toolItem.tool.function.name, toolItem) + } else { + tools.push(toolItem) + } + } + + return { tools, runtimeToolMap } + } + return { name: 'tool', ...restOptions, @@ -213,9 +296,10 @@ export const toolPlugin = ( onBeforeRequest: async (context) => { const { requestBody } = context - const tools = await getTools() + const existingTools = Array.isArray(requestBody.tools) ? requestBody.tools : [] + const { tools } = await resolveTools(context, existingTools) if (tools && tools.length > 0) { - requestBody.tools = tools + requestBody.tools = existingTools.length ? [...existingTools, ...tools] : tools } return restOptions.onBeforeRequest?.(context) @@ -242,6 +326,8 @@ export const toolPlugin = ( assistantMessage: currentMessage as AssistantMessageWithState, }) + const { runtimeToolMap } = await resolveTools(context) + const toolCallPromises = currentMessage.tool_calls.map(async (toolCall) => { const now = Math.floor(Date.now() / 1000) let hasMeaningfulResult = false @@ -265,7 +351,12 @@ export const toolPlugin = ( toolCallStart(toolCall, contextWithToolMessage) try { - const result = callTool(toolCall, contextWithToolMessage) + const functionToolCall = isFunctionToolCall(toolCall) ? toolCall : undefined + const runtimeTool = functionToolCall ? runtimeToolMap.get(functionToolCall.function.name) : undefined + const result = + runtimeTool && functionToolCall + ? runtimeTool.handler(functionToolCall, contextWithToolMessage) + : callTool(toolCall, contextWithToolMessage) // 将 Promise 或异步迭代器统一转换为异步生成器 const iterator = normalizeToAsyncGenerator(result) diff --git a/packages/kit/src/message/skills/browserSkillLoader.ts b/packages/kit/src/message/skills/browserSkillLoader.ts new file mode 100644 index 000000000..3604170af --- /dev/null +++ b/packages/kit/src/message/skills/browserSkillLoader.ts @@ -0,0 +1,102 @@ +import type { SkillFile } from './types' +import { isTextSkillFilePath, normalizeSkillPath } from './utils' + +type BrowserFile = Pick & { + webkitRelativePath?: string +} + +type BrowserFileHandle = { + kind: 'file' + name: string + getFile: () => Promise +} + +type BrowserDirectoryHandle = { + kind: 'directory' + name: string + entries: () => AsyncIterable<[string, BrowserFileHandle | BrowserDirectoryHandle]> +} + +/** + * 前端 FileList 适配器。 + * + * 支持 选出的文件列表。 + */ +export const loadSkillFilesFromFileList = async (fileList: ArrayLike): Promise => { + const files = Array.from({ length: fileList.length }, (_, index) => fileList[index]).filter( + (file): file is BrowserFile => Boolean(file), + ) + + return Promise.all( + files.map((file) => { + const path = file.webkitRelativePath || file.name + return browserFileToSkillFile(file, stripRootDirectory(path)) + }), + ) +} + +/** + * 前端 FileSystemDirectoryHandle 适配器。 + * + * 支持 window.showDirectoryPicker() 返回的目录句柄。 + */ +export const loadSkillFilesFromDirectoryHandle = async ( + directoryHandle: BrowserDirectoryHandle, +): Promise => { + const result: SkillFile[] = [] + + const walk = async (directory: BrowserDirectoryHandle, parentPath = '') => { + for await (const [name, handle] of directory.entries()) { + const path = parentPath ? `${parentPath}/${name}` : name + + if (handle.kind === 'directory') { + await walk(handle, path) + continue + } + + result.push(await browserFileToSkillFile(await handle.getFile(), path)) + } + } + + await walk(directoryHandle) + return result.sort((a, b) => a.path.localeCompare(b.path)) +} + +const browserFileToSkillFile = async (file: BrowserFile, rawPath: string): Promise => { + const path = normalizeSkillPath(rawPath) + + if (!path) { + throw new Error(`Invalid skill file path: ${rawPath}`) + } + + if (isTextSkillFilePath(path)) { + return { + path, + kind: 'text', + content: await file.text(), + mimeType: file.type, + size: file.size, + lastModified: file.lastModified, + } + } + + return { + path, + kind: 'binary', + content: await file.arrayBuffer(), + mimeType: file.type, + size: file.size, + lastModified: file.lastModified, + } +} + +const stripRootDirectory = (path: string) => { + const normalized = path.split('\\').join('/') + const parts = normalized.split('/').filter(Boolean) + + if (parts.length <= 1) { + return normalized + } + + return parts.slice(1).join('/') +} diff --git a/packages/kit/src/message/skills/fsSkillLoader.ts b/packages/kit/src/message/skills/fsSkillLoader.ts new file mode 100644 index 000000000..e8bc962da --- /dev/null +++ b/packages/kit/src/message/skills/fsSkillLoader.ts @@ -0,0 +1,74 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import { join, relative } from 'node:path' +import type { SkillFile } from './types' +import { isTextSkillFilePath, normalizeSkillPath } from './utils' + +export interface FsSkillFileLoaderOptions { + /** + * 忽略的目录名。 + */ + ignoredDirectories?: string[] +} + +/** + * 后端/Node 侧目录适配器。 + * + * 只负责把本地目录读取为 SkillFile[],不解析 skill 语义。 + */ +export const loadSkillFilesFromFs = async ( + root: string, + options: FsSkillFileLoaderOptions = {}, +): Promise => { + const ignoredDirectories = new Set(options.ignoredDirectories ?? ['.git', 'node_modules']) + const result: SkillFile[] = [] + + const walk = async (directory: string) => { + const entries = await readdir(directory, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = join(directory, entry.name) + + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + await walk(fullPath) + } + continue + } + + if (!entry.isFile()) { + continue + } + + const fileStat = await stat(fullPath) + if (!fileStat.isFile()) { + continue + } + + const path = normalizeSkillPath(relative(root, fullPath)) + if (!path) { + continue + } + + if (isTextSkillFilePath(path)) { + result.push({ + path, + kind: 'text', + content: await readFile(fullPath, 'utf-8'), + size: fileStat.size, + lastModified: fileStat.mtimeMs, + }) + } else { + result.push({ + path, + kind: 'binary', + content: await readFile(fullPath), + size: fileStat.size, + lastModified: fileStat.mtimeMs, + }) + } + } + } + + await walk(root) + return result.sort((a, b) => a.path.localeCompare(b.path)) +} diff --git a/packages/kit/src/message/skills/skillLoader.ts b/packages/kit/src/message/skills/skillLoader.ts new file mode 100644 index 000000000..c985b0eb0 --- /dev/null +++ b/packages/kit/src/message/skills/skillLoader.ts @@ -0,0 +1,248 @@ +import type { ChatCompletionFunctionTool } from 'openai/resources' +import { parse as parseYaml } from 'yaml' +import type { SkillDefinition } from '../plugins/skillPlugin' +import type { SkillFile, SkillFileResource } from './types' +import { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' + +export interface SkillLoaderWarning { + /** + * 警告类型,便于 UI 或日志分类。 + */ + code: string + /** + * 人类可读的警告说明。 + */ + message: string + /** + * 关联文件路径。 + */ + path?: string +} + +export interface LoadedSkill { + /** + * 从文件列表解析出的 SkillDefinition。 + */ + skill: SkillDefinition + /** + * 加载过程中的非致命问题。 + */ + warnings: SkillLoaderWarning[] +} + +export interface SkillLoaderOptions { + /** + * Skill 入口文件名。 + */ + entryFile?: string + /** + * 严格模式。开启后,tools.json 等关键文件解析失败会直接抛错。 + */ + strict?: boolean +} + +/** + * 将标准化后的 skill 文件列表解析为 SkillDefinition。 + * + * 该类不关心文件来自前端 FileSystemHandle、后端 fs、zip 还是远程接口; + * 调用方只需要先把文件来源适配为 SkillFile[]。 + */ +export class SkillLoader { + private entryFile: string + private strict: boolean + + constructor(options: SkillLoaderOptions = {}) { + this.entryFile = options.entryFile ?? 'SKILL.md' + this.strict = options.strict ?? false + } + + load(files: SkillFile[]): LoadedSkill { + const warnings: SkillLoaderWarning[] = [] + const normalizedFiles = this.normalizeFiles(files, warnings) + const entryFile = normalizedFiles.find((file) => file.path === this.entryFile) + + if (!entryFile) { + throw new Error(`Skill entry file "${this.entryFile}" is missing.`) + } + + if (entryFile.kind !== 'text') { + throw new Error(`Skill entry file "${this.entryFile}" must be a text file.`) + } + + const { frontmatter, body } = parseMarkdownFrontmatter(entryFile.content) + const frontmatterMetadata = getRecord(frontmatter.metadata) + const skillFiles: SkillFileResource[] = [] + const tools: ChatCompletionFunctionTool[] = [] + + for (const file of normalizedFiles) { + if (file.path === this.entryFile) { + continue + } + + if (file.kind === 'binary') { + skillFiles.push({ + ...file, + id: file.path, + }) + continue + } + + if (!isTextSkillFilePath(file.path)) { + warnings.push({ + code: 'unsupported-text-file-ignored', + message: 'Only markdown, text, and json files are converted to text skill files.', + path: file.path, + }) + continue + } + + if (isToolsFile(file.path)) { + try { + tools.push(...parseTools(file.content)) + } catch (error) { + this.handleWarning(warnings, { + code: 'tools-parse-failed', + message: error instanceof Error ? error.message : String(error), + path: file.path, + }) + } + continue + } + + skillFiles.push({ + ...file, + id: file.path, + }) + } + + return { + skill: { + name: getString(frontmatter.name) || getFallbackSkillName(this.entryFile), + description: getString(frontmatter.description) || '', + instructions: body.trim(), + tools: tools.length ? tools : undefined, + files: skillFiles.length ? skillFiles : undefined, + metadata: { + ...frontmatterMetadata, + homepage: getString(frontmatter.homepage), + frontmatter, + }, + }, + warnings, + } + } + + private normalizeFiles(files: SkillFile[], warnings: SkillLoaderWarning[]) { + const result: SkillFile[] = [] + const seenPaths = new Set() + + for (const file of files) { + const path = normalizeSkillPath(file.path) + + if (!path) { + this.handleWarning(warnings, { + code: 'invalid-path', + message: `Invalid skill file path: ${file.path}`, + path: file.path, + }) + continue + } + + if (seenPaths.has(path)) { + this.handleWarning(warnings, { + code: 'duplicate-path', + message: `Duplicate skill file path: ${path}`, + path, + }) + continue + } + + seenPaths.add(path) + result.push({ ...file, path } as SkillFile) + } + + return result.sort((a, b) => a.path.localeCompare(b.path)) + } + + private handleWarning(warnings: SkillLoaderWarning[], warning: SkillLoaderWarning) { + if (this.strict) { + throw new Error(warning.path ? `${warning.path}: ${warning.message}` : warning.message) + } + + warnings.push(warning) + } +} + +const parseMarkdownFrontmatter = (content: string) => { + if (!content.startsWith('---')) { + return { + frontmatter: {} as Record, + body: content, + } + } + + const endIndex = content.indexOf('\n---', 3) + if (endIndex === -1) { + return { + frontmatter: {} as Record, + body: content, + } + } + + const rawFrontmatter = content.slice(3, endIndex).trim() + const body = content.slice(endIndex + 4) + + return { + frontmatter: parseYamlFrontmatter(rawFrontmatter), + body, + } +} + +const parseYamlFrontmatter = (rawFrontmatter: string) => { + const parsed = parseYaml(rawFrontmatter) + return getRecord(parsed) ?? {} +} + +const getString = (value: unknown) => (typeof value === 'string' ? value : undefined) + +const getRecord = (value: unknown) => + value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined + +const isToolsFile = (path: string) => { + const normalizedPath = path.toLowerCase() + return normalizedPath === 'tools.json' || normalizedPath.startsWith('tools/') +} + +const parseTools = (content: string): ChatCompletionFunctionTool[] => { + const data = JSON.parse(content) + const tools = Array.isArray(data) ? data : [data] + return tools.map((tool, index) => { + if (!isChatCompletionFunctionTool(tool)) { + throw new Error(`Invalid function tool at index ${index}. Skill tools must be ChatCompletionFunctionTool.`) + } + + return tool + }) +} + +const isChatCompletionFunctionTool = (tool: unknown): tool is ChatCompletionFunctionTool => { + if (!tool || typeof tool !== 'object') { + return false + } + + const candidate = tool as Partial + return ( + candidate.type === 'function' && + Boolean(candidate.function) && + typeof candidate.function === 'object' && + typeof candidate.function.name === 'string' + ) +} + +const getResourceTitle = (path: string) => { + const filename = path.split('/').at(-1) || path + const ext = getExtension(filename) + return ext ? filename.slice(0, -ext.length) : filename +} + +const getFallbackSkillName = (entryFile: string) => getResourceTitle(entryFile) diff --git a/packages/kit/src/message/skills/types.ts b/packages/kit/src/message/skills/types.ts new file mode 100644 index 000000000..b8f5596f4 --- /dev/null +++ b/packages/kit/src/message/skills/types.ts @@ -0,0 +1,48 @@ +export type SkillFileKind = 'text' | 'binary' + +/** + * Skill 文件的公共数据模型。 + * + * 同时支持 browser (File API / showDirectoryPicker) 和 Node.js (fs) 两种环境。 + */ +export interface BaseSkillFile { + /** + * 基于 skill 根目录的相对路径。必须使用 / 分隔,不能以 / 开头,不能包含 ..。 + */ + path: string + /** + * MIME 类型。 + */ + mimeType?: string + /** + * 文件大小(字节)。 + */ + size?: number + /** + * 最后修改时间(时间戳)。 + */ + lastModified?: number + /** + * 文件元数据。可放来源、优先级、版本号等业务字段。 + */ + metadata?: Record +} + +export interface TextSkillFile extends BaseSkillFile { + kind: 'text' + content: string +} + +export interface BinarySkillFile extends BaseSkillFile { + kind: 'binary' + content: ArrayBuffer | Uint8Array +} + +export type SkillFile = TextSkillFile | BinarySkillFile + +export type SkillFileResource = SkillFile & { + /** + * 文件唯一标识。在同一个 skill 内应保持唯一,默认使用 path。 + */ + id: string +} diff --git a/packages/kit/src/message/skills/utils.ts b/packages/kit/src/message/skills/utils.ts new file mode 100644 index 000000000..044b38b0e --- /dev/null +++ b/packages/kit/src/message/skills/utils.ts @@ -0,0 +1,26 @@ +export const normalizeSkillPath = (path: string) => { + const normalized = path + .split('\\') + .join('/') + .replace(/^\.\/+/, '') + + if (!normalized || normalized.startsWith('/') || normalized.includes('\0')) { + return null + } + + if (normalized.split('/').some((part) => part === '..' || part === '')) { + return null + } + + return normalized +} + +export const isTextSkillFilePath = (path: string) => { + return ['.md', '.txt', '.json'].includes(getExtension(path)) +} + +export const getExtension = (path: string) => { + const filename = path.split('/').at(-1) || path + const index = filename.lastIndexOf('.') + return index === -1 ? '' : filename.slice(index).toLowerCase() +} diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/SKILL.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/SKILL.md new file mode 100644 index 000000000..feacd704f --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/SKILL.md @@ -0,0 +1,154 @@ +--- +name: vue-best-practices +description: MUST be used for Vue.js tasks. Strongly recommends Composition API with ` + + +``` + +## Common Animation Patterns + +### Pulse on Success + +```vue + + + + + +``` + +### Highlight on Change + +```vue + + + + + +``` + +### Bounce Attention + +```vue + + + + + +``` + +## Using animationend Event + +Instead of `setTimeout`, use the `animationend` event for cleaner code: + +```vue + + + +``` + +## Composable for Reusable Animations + +```javascript +// composables/useAnimation.js +import { ref } from 'vue' + +export function useAnimation(duration = 500) { + const isAnimating = ref(false) + + function trigger() { + isAnimating.value = true + setTimeout(() => { + isAnimating.value = false + }, duration) + } + + return { + isAnimating, + trigger + } +} +``` + +```vue + + + +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-state-driven-technique.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-state-driven-technique.md new file mode 100644 index 000000000..26b012018 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-state-driven-technique.md @@ -0,0 +1,291 @@ +--- +title: State-driven Animations with CSS Transitions and Style Bindings +impact: LOW +impactDescription: Combining Vue's reactive style bindings with CSS transitions creates smooth, interactive animations +type: best-practice +tags: [vue3, animation, css, transition, style-binding, state, interactive] +--- + +# State-driven Animations with CSS Transitions and Style Bindings + +**Impact: LOW** - For responsive, interactive animations that react to user input or state changes, combine Vue's dynamic style bindings with CSS transitions. This creates smooth animations that interpolate values in real-time based on state. + +## Task List + +- Use `:style` binding for dynamic properties that change frequently +- Add CSS `transition` property to smoothly animate between values +- Consider using `transform` and `opacity` for GPU-accelerated animations +- For complex value interpolation, use watchers with animation libraries + +## Basic Pattern + +```vue + + + + + +``` + +## Common Use Cases + +### Following Mouse Position + +```vue + + + + + +``` + +### Progress Animation + +```vue + + + + + +``` + +### Scroll-based Animation + +```vue + + + + + +``` + +### Color Theme Transition + +```vue + + + + + +``` + +## Advanced: Numerical Tweening with Watchers + +For smooth number animations (counters, stats), use watchers with animation libraries: + +```vue + + + +``` + +## Performance Considerations + +```vue + +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-async.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-async.md new file mode 100644 index 000000000..b39310d2e --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-async.md @@ -0,0 +1,97 @@ +--- +title: Async Component Best Practices +impact: MEDIUM +impactDescription: Poor async component strategy can delay interactivity in SSR apps and create loading UI flicker +type: best-practice +tags: [vue3, async-components, ssr, hydration, performance, ux] +--- + +# Async Component Best Practices + +**Impact: MEDIUM** - Async components should reduce JavaScript cost without degrading perceived performance. Focus on hydration timing in SSR and stable loading UX. + +## Task List + +- Use lazy hydration strategies for non-critical SSR component trees +- Import only the hydration helpers you actually use +- Keep `loadingComponent` delay near the default `200ms` unless real UX data suggests otherwise +- Configure `delay` and `timeout` together for predictable loading behavior + +## Use Lazy Hydration Strategies in SSR + +In Vue 3.5+, async components can delay hydration until idle time, visibility, media query match, or user interaction. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Prevent Loading Spinner Flicker + +Avoid showing loading UI immediately for components that usually resolve quickly. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Delay Guidelines + +| Scenario | Recommended Delay | +|----------|-------------------| +| Small component, fast network | `200ms` | +| Known heavy component | `100ms` | +| Background or non-critical UI | `300-500ms` | diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-data-flow.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-data-flow.md new file mode 100644 index 000000000..e1add1e8a --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-data-flow.md @@ -0,0 +1,307 @@ +--- +title: Component Data Flow Best Practices +impact: HIGH +impactDescription: Clear data flow between components prevents state bugs, stale UI, and brittle coupling +type: best-practice +tags: [vue3, props, emits, v-model, provide-inject, data-flow, typescript] +--- + +# Component Data Flow Best Practices + +**Impact: HIGH** - Vue components stay reliable when data flow is explicit: props go down, events go up, `v-model` handles two-way bindings, and provide/inject supports cross-tree dependencies. Blurring these boundaries leads to stale state, hidden coupling, and hard-to-debug UI. + +The main principle of data flow in Vue.js is **Props Down / Events Up**. This is the most maintainable default, and one-way flow scales well. + +## Task List + +- Treat props as read-only inputs +- Use props/emit for component communication; reserve refs for imperative actions +- When refs are required for imperative APIs, type them with template refs +- Emit events instead of mutating parent state directly +- Use `defineModel` for v-model in modern Vue (3.4+) +- Handle v-model modifiers deliberately in child components +- Use symbols for provide/inject keys to avoid props drilling (over ~3 layers) +- Keep mutations in the provider or expose explicit actions +- In TypeScript projects, prefer type-based `defineProps`, `defineEmits`, and `InjectionKey` + +## Props: One-Way Data Down + +Props are inputs. Do not mutate them in the child. + +**BAD:** +```vue + +``` + +**GOOD:** + +If state needs to change, emit an event, use `v-model` or create a local copy. + +## Prefer props/emit over component refs + +**BAD:** +```vue + + + +``` + +**GOOD:** +```vue + + + +``` + +## Type component refs when imperative access is required + +Prefer props/emits by default. When a parent must call an exposed child method, type the ref explicitly and expose only the intended API from the child with `defineExpose`. + +**BAD:** +```vue + + + +``` + +**GOOD:** +```vue + + +``` + +```vue + + + + +``` + +## Emits: Explicit Events Up + +Component events do not bubble. If a parent needs to know about an event, re-emit it explicitly. + +**BAD:** +```vue + + +``` + +**GOOD:** +```vue + + + + +``` + +**Event naming:** use kebab-case in templates and camelCase in script: +```vue + + + +``` + +## `v-model`: Predictable Two-Way Bindings + +Use `defineModel` by default for component bindings and emit updates on input. Only use the `modelValue` + `update:modelValue` pattern if you are on Vue < 3.4. + +**BAD:** +```vue + + + +``` + +**GOOD (Vue 3.4+):** +```vue + + + +``` + +**GOOD (Vue < 3.4):** +```vue + + + +``` + +If you need the updated value immediately after a change, use the input event value or `nextTick` in the parent. + +## Provide/Inject: Shared Context Without Prop Drilling + +Use provide/inject for cross-tree state, but keep mutations centralized in the provider and expose explicit actions. + +**BAD:** +```vue +// Provider.vue +provide('theme', reactive({ dark: false })) + +// Consumer.vue +const theme = inject('theme') +// Mutating shared state from any depth becomes hard to track +theme.dark = true +``` + +**GOOD:** +```vue +// Provider.vue +const theme = reactive({ dark: false }) +const toggleTheme = () => { theme.dark = !theme.dark } + +provide(themeKey, readonly(theme)) +provide(themeActionsKey, { toggleTheme }) + +// Consumer.vue +const theme = inject(themeKey) +const { toggleTheme } = inject(themeActionsKey) +``` + +Use symbols for keys to avoid collisions in large apps: +```ts +export const themeKey = Symbol('theme') +export const themeActionsKey = Symbol('theme-actions') +``` + +## Use TypeScript Contracts for Public Component APIs + +In TypeScript projects, type component boundaries directly with `defineProps`, `defineEmits`, and `InjectionKey` so invalid payloads and mismatched injections fail at compile time. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-fallthrough-attrs.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-fallthrough-attrs.md new file mode 100644 index 000000000..5362fa4af --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-fallthrough-attrs.md @@ -0,0 +1,174 @@ +--- +title: Component Fallthrough Attributes Best Practices +impact: MEDIUM +impactDescription: Incorrect $attrs access and reactivity assumptions can cause undefined values and watchers that never run +type: best-practice +tags: [vue3, attrs, fallthrough-attributes, composition-api, reactivity] +--- + +# Component Fallthrough Attributes Best Practices + +**Impact: MEDIUM** - Fallthrough attributes are straightforward once you follow Vue's conventions: hyphenated names use bracket notation, listener keys are camelCase `onX`, and `useAttrs()` is current-but-not-reactive. + +## Task List + +- Access hyphenated attribute names with bracket notation (for example `attrs['data-testid']`) +- Access event listeners with camelCase `onX` keys (for example `attrs.onClick`) +- Do not `watch()` values returned from `useAttrs()`; those watchers do not trigger on attr changes +- Use `onUpdated()` for attr-driven side effects +- Promote frequently observed attrs to props when reactive observation is required + +## Access Attribute and Listener Keys Correctly + +Hyphenated attribute names preserve their original casing in JavaScript, so dot notation does not work for keys that include `-`. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +### Naming Reference + +| Parent Usage | Access in `attrs` | +|--------------|-------------------| +| `class="foo"` | `attrs.class` | +| `data-id="123"` | `attrs['data-id']` | +| `aria-label="..."` | `attrs['aria-label']` | +| `foo-bar="baz"` | `attrs['foo-bar']` | +| `@click="fn"` | `attrs.onClick` | +| `@custom-event="fn"` | `attrs.onCustomEvent` | +| `@update:modelValue="fn"` | `attrs['onUpdate:modelValue']` | + +## `useAttrs()` Is Not Reactive + +`useAttrs()` always reflects the latest values, but it is intentionally not reactive for watcher tracking. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Common Patterns + +### Check for optional attrs safely + +```vue + +``` + +### Forward listeners after internal logic + +```vue + + + +``` + +## TypeScript Notes + +`useAttrs()` is typed as `Record`, so cast individual keys when needed. + +```vue + +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-keep-alive.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-keep-alive.md new file mode 100644 index 000000000..f887691fe --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-keep-alive.md @@ -0,0 +1,137 @@ +--- +title: KeepAlive Component Best Practices +impact: HIGH +impactDescription: KeepAlive caches component instances; misuse causes stale data, memory growth, or unexpected lifecycle behavior +type: best-practice +tags: [vue3, keepalive, cache, performance, router, dynamic-components] +--- + +# KeepAlive Component Best Practices + +**Impact: HIGH** - `` caches component instances instead of destroying them. Use it to preserve state across switches, but manage cache size and freshness explicitly to avoid memory growth or stale UI. + +## Task List + +- Use KeepAlive only where state preservation improves UX +- Set a reasonable `max` to cap cache size +- Declare component names for include/exclude matching +- Use `onActivated`/`onDeactivated` for cache-aware logic +- Decide how and when cached views refresh their data +- Avoid caching memory-heavy or security-sensitive views + +## When to Use KeepAlive + +Use KeepAlive when switching between views where state should persist (tabs, multi-step forms, dashboards). Avoid it when each visit should start fresh. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## When NOT to Use KeepAlive + +- Search or filter pages where users expect fresh results +- Memory-heavy components (maps, large tables, media players) +- Sensitive flows where data must be cleared on exit +- Components with heavy background activity you cannot pause + +## Limit and Control the Cache + +Always cap cache size with `max` and restrict caching to specific components when possible. + +```vue + +``` + +## Ensure Component Names Match include/exclude + +`include` and `exclude` match the component `name` option. Explicitly set names for reliable caching. + +```vue + + +``` + +```vue + +``` + +## Cache Invalidation Strategies + +Vue 3 has no direct API to remove a specific cached instance. Use keys or dynamic include/exclude to force refreshes. + +```vue + + + +``` + +## Lifecycle Hooks for Cached Components + +Cached components are not destroyed on switch. Use activation hooks for refresh and cleanup. + +```vue + +``` + +## Router Caching and Freshness + +Decide whether navigation should show cached state or a fresh view. A common pattern is to key by route when params change. + +```vue + +``` + +If you want cache reuse but fresh data, refresh in `onActivated` and compare query/params before fetching. diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-slots.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-slots.md new file mode 100644 index 000000000..f77a91c5c --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-slots.md @@ -0,0 +1,216 @@ +--- +title: Component Slots Best Practices +impact: MEDIUM +impactDescription: Poor slot API design causes empty DOM wrappers, weak TypeScript safety, brittle defaults, and unnecessary component overhead +type: best-practice +tags: [vue3, slots, components, typescript, composables] +--- + +# Component Slots Best Practices + +**Impact: MEDIUM** - Slots are a core component API surface in Vue. Structure them intentionally so templates stay predictable, typed, and performant. + +## Task List + +- Use shorthand syntax for named slots (`#` instead of `v-slot:`) +- Render optional slot wrapper elements only when slot content exists (`$slots` checks) +- Type scoped slot contracts with `defineSlots` in TypeScript components +- Provide fallback content for optional slots +- Prefer composables over renderless components for pure logic reuse + +## Shorthand syntax for named slots + +**BAD:** +```vue + + + +``` + +**GOOD:** +```vue + + + +``` + +## Conditionally Render Optional Slot Wrappers + +Use `$slots` checks when wrapper elements add spacing, borders, or layout constraints. + +**BAD:** +```vue + + +``` + +**GOOD:** +```vue + + +``` + +## Type Scoped Slot Props with defineSlots + +In ` + + +``` + +**GOOD:** +```vue + + + + +``` + +## Provide Slot Fallback Content + +Fallback content makes components resilient when parents omit optional slots. + +**BAD:** +```vue + + +``` + +**GOOD:** +```vue + + +``` + +## Prefer Composables for Pure Logic Reuse + +Renderless components are still useful for slot-driven composition, but composables are usually cleaner for logic-only reuse. + +**BAD:** +```vue + + + + +``` + +**GOOD:** +```ts +// composables/useMouse.ts +import { ref, onMounted, onUnmounted } from 'vue' + +export function useMouse() { + const x = ref(0) + const y = ref(0) + + function onMove(event: MouseEvent) { + x.value = event.pageX + y.value = event.pageY + } + + onMounted(() => window.addEventListener('mousemove', onMove)) + onUnmounted(() => window.removeEventListener('mousemove', onMove)) + + return { x, y } +} +``` + +```vue + + + + +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-suspense.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-suspense.md new file mode 100644 index 000000000..4d9ecab9d --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-suspense.md @@ -0,0 +1,228 @@ +--- +title: Suspense Component Best Practices +impact: MEDIUM +impactDescription: Suspense coordinates async dependencies with fallback UI; misconfiguration leads to missing loading states or confusing UX +type: best-practice +tags: [vue3, suspense, async-components, async-setup, loading, fallback, router, transition, keepalive] +--- + +# Suspense Component Best Practices + +**Impact: MEDIUM** - `` coordinates async dependencies (async components or async setup) and renders a fallback while they resolve. Misconfiguration leads to missing loading states, empty renders, or subtle UX bugs. + +## Task List + +- Wrap default and fallback slot content in a single root node +- Use `timeout` when you need the fallback to appear on reverts +- Force root replacement with `:key` when you need Suspense to re-trigger +- Add `suspensible` to nested Suspense boundaries (Vue 3.3+) +- Use `@pending`, `@resolve`, and `@fallback` for programmatic loading state +- Nest `RouterView` -> `Transition` -> `KeepAlive` -> `Suspense` in that order +- Keep Suspense usage centralized and documented in production + +## Single Root in Default and Fallback Slots + +Suspense tracks a single immediate child in both slots. Wrap multiple elements in a single element or component. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Fallback Timing on Reverts (`timeout`) + +When Suspense is already resolved and new async work starts, the previous content remains visible until the timeout elapses. Use `timeout="0"` for immediate fallback or a short delay to avoid flicker. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Pending State Only Re-triggers on Root Replacement + +Once resolved, Suspense only re-enters pending when the root node of the default slot changes. If async work happens deeper in the tree, no fallback appears. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Use `suspensible` for Nested Suspense (Vue 3.3+) + +Nested Suspense boundaries need `suspensible` on the inner boundary so the parent can coordinate loading state. Without it, inner async content may render empty nodes until resolved. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Track Loading with Suspense Events + +Use `@pending`, `@resolve`, and `@fallback` for analytics, global loading indicators, or coordinating UI outside the Suspense boundary. + +```vue + + + +``` + +## Recommended Nesting with RouterView, Transition, KeepAlive + +When combining these components, the nesting order should be `RouterView` -> `Transition` -> `KeepAlive` -> `Suspense` so each wrapper works correctly. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Treat Suspense Cautiously in Production + +In production code, keep Suspense boundaries minimal, document where they are used, and have a fallback loading strategy if you ever need to replace or refactor them. diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-teleport.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-teleport.md new file mode 100644 index 000000000..db48db2d7 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-teleport.md @@ -0,0 +1,108 @@ +--- +title: Teleport Component Best Practices +impact: MEDIUM +impactDescription: Teleport renders content outside the component's DOM position, which is essential for overlays but affects styling and layout +type: best-practice +tags: [vue3, teleport, modal, overlay, positioning, responsive] +--- + +# Teleport Component Best Practices + +**Impact: MEDIUM** - `` renders part of a component's template in a different place in the DOM while preserving the Vue component hierarchy. Use it for overlays (modals, toasts, tooltips) or any UI that must escape stacking contexts, overflow, or fixed positioning constraints. + +## Task List + +- Teleport overlays to `body` or a dedicated container outside the app root +- Keep a shared target for similar UI (`#modals`, `#notifications`) and control layering with order or z-index +- Use `:disabled` for responsive layouts that should render inline on small screens +- Remember props, emits, and provide/inject still work through teleport +- Avoid relying on parent stacking contexts or transforms for teleported UI + +## Teleport Overlays Out of Transformed Containers + +When an ancestor has `transform`, `filter`, or `perspective`, fixed-position overlays can behave like they are locally positioned. Teleport escapes that context. + +**BAD:** +```vue + + + +``` + +**GOOD:** +```vue + +``` + +## Responsive Layouts with `disabled` + +Use `:disabled` to render inline on mobile and teleport on larger screens: + +```vue + + + +``` + +## Logical Hierarchy Is Preserved + +Teleport changes DOM position, not the Vue component tree. Props, emits, slots, and provide/inject still work: + +```vue + +``` + +## Multiple Teleports to the Same Target + +Teleports to the same target append in declaration order: + +```vue + +``` + +Use a shared container to keep stacking predictable, and apply z-index only when you need explicit layering. diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition-group.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition-group.md new file mode 100644 index 000000000..d0339ff4d --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition-group.md @@ -0,0 +1,128 @@ +--- +title: TransitionGroup Component Best Practices +impact: MEDIUM +impactDescription: TransitionGroup animates list items; missing keys or misuse leads to broken list transitions +type: best-practice +tags: [vue3, transition-group, animation, lists, keys] +--- + +# TransitionGroup Component Best Practices + +**Impact: MEDIUM** - `` animates lists of items entering, leaving, and moving. Use it for `v-for` lists or dynamic collections where individual items change over time. + +## Task List + +- Use `` only for lists and repeated items +- Provide unique, stable keys for every direct child +- Use `tag` when you need semantic or layout wrappers +- Avoid the `mode` prop (not supported) +- Use JavaScript hooks for staggered effects + +## Use TransitionGroup for Lists + +`` is designed for list items. Use `tag` to control the wrapper element when needed. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Always Provide Stable Keys + +Keys are required. Without stable keys, Vue cannot track item positions and animations break. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Do Not Use `mode` on TransitionGroup + +`mode` is only for `` because it swaps a single element. Use `` if you need in/out sequencing. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Stagger List Animations with Data Attributes + +For cascading list animations, pass the index to JavaScript hooks and compute delay per item. + +```vue + + + +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition.md new file mode 100644 index 000000000..e6abed783 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition.md @@ -0,0 +1,125 @@ +--- +title: Transition Component Best Practices +impact: MEDIUM +impactDescription: Transition animates a single element or component; incorrect structure or keys prevent animations +type: best-practice +tags: [vue3, transition, animation, performance, keys] +--- + +# Transition Component Best Practices + +**Impact: MEDIUM** - `` animates entering/leaving of a single element or component. It is ideal for toggling UI states, swapping views, or animating one component at a time. + +## Task List + +- Wrap a single element or component inside `` +- Provide a `key` when switching between same element types +- Use `mode="out-in"` when you need sequential swaps +- Prefer `transform` and `opacity` for smooth animations + +## Use Transition for a Single Root Element + +`` only supports one direct child. Wrap multiple nodes in a single element or component. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Force Transitions Between Same Element Types + +Vue reuses the same DOM element when the tag type does not change. Add `key` so Vue treats it as a new element and triggers enter/leave. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Use `mode` to Avoid Overlap During Swaps + +When swapping components or views, use `mode="out-in"` to prevent both from being visible at the same time. + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Animate `transform` and `opacity` for Performance + +Avoid layout-triggering properties such as `height`, `margin`, or `top`. Use `transform` and `opacity` for smooth, GPU-friendly transitions. + +**BAD:** +```css +.slide-enter-active, +.slide-leave-active { + transition: height 0.3s ease; +} + +.slide-enter-from, +.slide-leave-to { + height: 0; +} +``` + +**GOOD:** +```css +.slide-enter-active, +.slide-leave-active { + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.slide-enter-from { + transform: translateX(-12px); + opacity: 0; +} + +.slide-leave-to { + transform: translateX(12px); + opacity: 0; +} +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/composables.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/composables.md new file mode 100644 index 000000000..cb18a6f8a --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/composables.md @@ -0,0 +1,290 @@ +--- +title: Composable Organization Patterns +impact: MEDIUM +impactDescription: Well-structured composables improve maintainability, reusability, and update performance +type: best-practice +tags: [vue3, composables, composition-api, code-organization, api-design, readonly, utilities] +--- + +# Composable Organization Patterns + +**Impact: MEDIUM** - Treat composables as reusable, stateful building blocks and keep their code organized by feature concern. This keeps large components maintainable and prevents hard-to-debug mutation and API design issues. + +## Task List + +- Compose complex behavior from small, focused composables +- Use options objects for composables with multiple optional parameters +- Return readonly state when updates must flow through explicit actions +- Keep pure utility functions as plain utilities, not composables +- Organize composable and component code by feature concern, and extract composables when components grow + +## Compose Composables from Smaller Primitives + +**BAD:** +```vue + +``` + +**GOOD:** +```javascript +// composables/useEventListener.js +import { onMounted, onUnmounted, toValue } from 'vue' + +export function useEventListener(target, event, callback) { + onMounted(() => toValue(target).addEventListener(event, callback)) + onUnmounted(() => toValue(target).removeEventListener(event, callback)) +} +``` + +```javascript +// composables/useMouse.js +import { ref } from 'vue' +import { useEventListener } from './useEventListener' + +export function useMouse() { + const x = ref(0) + const y = ref(0) + + useEventListener(window, 'mousemove', (e) => { + x.value = e.pageX + y.value = e.pageY + }) + + return { x, y } +} +``` + +```javascript +// composables/useMouseInElement.js +import { computed } from 'vue' +import { useMouse } from './useMouse' + +export function useMouseInElement(elementRef) { + const { x, y } = useMouse() + + const isOutside = computed(() => { + if (!elementRef.value) return true + const rect = elementRef.value.getBoundingClientRect() + return x.value < rect.left || x.value > rect.right || + y.value < rect.top || y.value > rect.bottom + }) + + return { x, y, isOutside } +} +``` + +## Use Options Object Pattern for Composable Parameters + +**BAD:** +```javascript +export function useFetch(url, method, headers, timeout, retries, immediate) { + // hard to read and easy to misorder +} + +useFetch('/api/users', 'GET', null, 5000, 3, true) +``` + +**GOOD:** +```javascript +export function useFetch(url, options = {}) { + const { + method = 'GET', + headers = {}, + timeout = 30000, + retries = 0, + immediate = true + } = options + + // implementation + return { method, headers, timeout, retries, immediate } +} + +useFetch('/api/users', { + method: 'POST', + timeout: 5000, + retries: 3 +}) +``` + +```typescript +interface UseCounterOptions { + initial?: number + min?: number + max?: number + step?: number +} + +export function useCounter(options: UseCounterOptions = {}) { + const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options + // implementation +} +``` + +## Return Readonly State with Explicit Actions + +**BAD:** +```javascript +export function useCart() { + const items = ref([]) + const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0)) + return { items, total } // any consumer can mutate directly +} + +const { items } = useCart() +items.value.push({ id: 1, price: 10 }) +``` + +**GOOD:** +```javascript +import { ref, computed, readonly } from 'vue' + +export function useCart() { + const _items = ref([]) + + const total = computed(() => + _items.value.reduce((sum, item) => sum + item.price * item.quantity, 0) + ) + + function addItem(product, quantity = 1) { + const existing = _items.value.find(item => item.id === product.id) + if (existing) { + existing.quantity += quantity + return + } + _items.value.push({ ...product, quantity }) + } + + function removeItem(productId) { + _items.value = _items.value.filter(item => item.id !== productId) + } + + return { + items: readonly(_items), + total, + addItem, + removeItem + } +} +``` + +## Keep Utilities as Utilities + +**BAD:** +```javascript +export function useFormatters() { + const formatDate = (date) => new Intl.DateTimeFormat('en-US').format(date) + const formatCurrency = (amount) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount) + return { formatDate, formatCurrency } +} + +const { formatDate } = useFormatters() +``` + +**GOOD:** +```javascript +// utils/formatters.js +export function formatDate(date) { + return new Intl.DateTimeFormat('en-US').format(date) +} + +export function formatCurrency(amount) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amount) +} +``` + +```javascript +// composables/useInvoiceSummary.js +import { computed } from 'vue' +import { formatCurrency } from '@/utils/formatters' + +export function useInvoiceSummary(invoiceRef) { + const totalLabel = computed(() => formatCurrency(invoiceRef.value.total)) + return { totalLabel } +} +``` + +## Organize Composable and Component Code by Feature Concern + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +```javascript +// composables/useItems.js +import { ref, onMounted } from 'vue' + +export function useItems() { + const items = ref([]) + const loading = ref(false) + + async function fetchItems() { + loading.value = true + try { + items.value = await api.getItems() + } finally { + loading.value = false + } + } + + onMounted(fetchItems) + return { items, loading, fetchItems } +} +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/directives.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/directives.md new file mode 100644 index 000000000..8412fbc88 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/directives.md @@ -0,0 +1,162 @@ +--- +title: Directive Best Practices +impact: MEDIUM +impactDescription: Custom directives are powerful but easy to misuse; following patterns prevents leaks, invalid usage, and unclear abstractions +type: best-practice +tags: [vue3, directives, custom-directives, composition, typescript] +--- + +# Directive Best Practices + +**Impact: MEDIUM** - Directives are for low-level DOM access. Use them sparingly, keep them side-effect safe, and prefer components or composables when you need stateful or reusable UI behavior. + +## Task List + +- Use directives only when you need direct DOM access +- Do not mutate directive arguments or binding objects +- Clean up timers, listeners, and observers in `unmounted` +- Register directives in ` + + +``` + +## Clean Up Side Effects in `unmounted` + +Any timers, listeners, or observers must be removed to avoid leaks. + +```ts +const vResize = { + mounted(el) { + const observer = new ResizeObserver(() => {}) + observer.observe(el) + el._observer = observer + }, + unmounted(el) { + el._observer?.disconnect() + } +} +``` + +## Prefer Function Shorthand for Single-Hook Directives + +If you only need `mounted`/`updated`, use the function form. + +```ts +const vAutofocus = (el) => el.focus() +``` + +## Use the `v-` Prefix and Script Setup Registration + +```vue + + + +``` + +## Type Custom Directives in TypeScript Projects + +Use `Directive` so `binding.value` is typed, and augment Vue's template types so directives are recognized in SFC templates. + +**BAD:** +```ts +// Untyped directive value and no template type augmentation +export const vHighlight = { + mounted(el, binding) { + el.style.backgroundColor = binding.value + } +} +``` + +**GOOD:** +```ts +import type { Directive } from 'vue' + +type HighlightValue = string + +export const vHighlight = { + mounted(el, binding) { + el.style.backgroundColor = binding.value + } +} satisfies Directive + +declare module 'vue' { + interface ComponentCustomProperties { + vHighlight: typeof vHighlight + } +} +``` + +## Handle SSR with `getSSRProps` + +Directive hooks such as `mounted` and `updated` do not run during SSR. If a directive sets attributes/classes that affect rendered HTML, provide an SSR equivalent via `getSSRProps` to avoid hydration mismatches. + +**BAD:** +```ts +const vTooltip = { + mounted(el, binding) { + el.setAttribute('data-tooltip', binding.value) + el.classList.add('has-tooltip') + } +} +``` + +**GOOD:** +```ts +const vTooltip = { + mounted(el, binding) { + el.setAttribute('data-tooltip', binding.value) + el.classList.add('has-tooltip') + }, + getSSRProps(binding) { + return { + 'data-tooltip': binding.value, + class: 'has-tooltip' + } + } +} +``` + +## Prefer Declarative Templates When Possible + +If a standard attribute or binding works, use it instead of a directive. + +## Decide Between Directives and Components + +Use a directive for DOM-level behavior. Use a component when behavior affects structure, state, or rendering. diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md new file mode 100644 index 000000000..44f98ff45 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md @@ -0,0 +1,159 @@ +--- +title: Avoid Excessive Component Abstraction in Large Lists +impact: MEDIUM +impactDescription: Each component instance has memory and render overhead - abstractions multiply this in lists +type: efficiency +tags: [vue3, performance, components, abstraction, lists, optimization] +--- + +# Avoid Excessive Component Abstraction in Large Lists + +**Impact: MEDIUM** - Component instances are more expensive than plain DOM nodes. While abstractions improve code organization, unnecessary nesting creates overhead. In large lists, this overhead multiplies - 100 items with 3 levels of abstraction means 300+ component instances instead of 100. + +Don't avoid abstraction entirely, but be mindful of component depth in frequently-rendered elements like list items. + +## Task List + +- Review list item components for unnecessary wrapper components +- Consider flattening component hierarchies in hot paths +- Use native elements when a component adds no value +- Profile component counts using Vue DevTools +- Focus optimization efforts on the most-rendered components + +**BAD:** +```vue + + + + + + + +``` + +**GOOD:** +```vue + + + + + + + + + +``` + +## When Abstraction Is Still Worth It + +```vue + + + + + + + + + + + + + + + +``` + +## Measuring Component Overhead + +```javascript +// In development, profile component counts +import { onMounted, getCurrentInstance } from 'vue' + +onMounted(() => { + const instance = getCurrentInstance() + let count = 0 + + function countComponents(vnode) { + if (vnode.component) count++ + if (vnode.children) { + vnode.children.forEach(child => { + if (child.component || child.children) countComponents(child) + }) + } + } + + // Use Vue DevTools instead for accurate counts + console.log('Check Vue DevTools Components tab for instance counts') +}) +``` + +## Alternatives to Wrapper Components + +```vue + + + + +{{ content }} + + +
+ +
+ + +``` + +## Impact Calculation + +| List Size | Components per Item | Total Instances | Memory Impact | +|-----------|---------------------|-----------------|---------------| +| 100 items | 1 (flat) | 100 | Baseline | +| 100 items | 3 (nested) | 300 | ~3x memory | +| 100 items | 5 (deeply nested) | 500 | ~5x memory | +| 1000 items | 1 (flat) | 1000 | High | +| 1000 items | 5 (deeply nested) | 5000 | Very High | diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md new file mode 100644 index 000000000..ce5f6880c --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md @@ -0,0 +1,182 @@ +--- +title: Use v-once and v-memo to Skip Unnecessary Updates +impact: MEDIUM +impactDescription: v-once skips all future updates for static content; v-memo conditionally memoizes subtrees +type: efficiency +tags: [vue3, performance, v-once, v-memo, optimization, directives] +--- + +# Use v-once and v-memo to Skip Unnecessary Updates + +**Impact: MEDIUM** - Vue re-evaluates templates on every reactive change. For content that never changes or changes infrequently, `v-once` and `v-memo` tell Vue to skip updates, reducing render work. + +Use `v-once` for truly static content and `v-memo` for conditionally-static content in lists. + +## Task List + +- Apply `v-once` to elements that use runtime data but never need updating +- Apply `v-memo` to list items that should only update on specific condition changes +- Verify memoized content doesn't need to respond to other state changes +- Profile with Vue DevTools to confirm update skipping + +## v-once: Render Once, Never Update + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + + + +``` + +## v-memo: Conditional Memoization for Lists + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + + + +``` + +## v-memo with Multiple Dependencies + +```vue + + + +``` + +## v-memo with Empty Array = v-once + +```vue + +``` + +## When NOT to Use These Directives + +```vue + +``` + +## Performance Comparison + +| Scenario | Without Directive | With v-once/v-memo | +|----------|-------------------|-------------------| +| Static header, parent re-renders 100x | Re-evaluated 100x | Evaluated 1x | +| 1000 items, selection changes | 1000 items re-render | 2 items re-render | +| Complex child component | Full re-render | Skipped if memoized | + +## Debugging Memoized Components + +```vue + +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-virtualize-large-lists.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-virtualize-large-lists.md new file mode 100644 index 000000000..78a8a1c63 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-virtualize-large-lists.md @@ -0,0 +1,187 @@ +--- +title: Virtualize Large Lists to Avoid DOM Overload +impact: HIGH +impactDescription: Rendering thousands of list items creates excessive DOM nodes, causing slow renders and high memory usage +type: efficiency +tags: [vue3, performance, virtual-list, large-data, dom, optimization] +--- + +# Virtualize Large Lists to Avoid DOM Overload + +**Impact: HIGH** - Rendering all items in a large list (hundreds or thousands) creates massive amounts of DOM nodes. Each node consumes memory, slows down initial render, and makes updates expensive. List virtualization only renders visible items, dramatically improving performance. + +Use a virtualization library when dealing with lists that could exceed 50-100 items, especially if items have complex content. + +## Task List + +- Identify lists that render more than 50-100 items +- Install a virtualization library (vue-virtual-scroller, @tanstack/vue-virtual) +- Replace standard `v-for` with virtualized component +- Ensure list items have consistent or estimable heights +- Test with realistic data volumes during development + +## Recommended Libraries + +| Library | Best For | Notes | +|---------|----------|-------| +| `vue-virtual-scroller` | General use, easy setup | Most popular, good defaults | +| `@tanstack/vue-virtual` | Complex layouts, headless | Framework-agnostic, flexible | +| `vue-virtual-scroll-grid` | Grid layouts | 2D virtualization | +| `vueuc/VVirtualList` | Naive UI projects | Part of Naive UI ecosystem | + +**BAD:** +```vue + + + +``` + +**GOOD:** +```vue + + + + + +``` + +## Using @tanstack/vue-virtual + +```vue + + + + + +``` + +## Dynamic Heights with vue-virtual-scroller + +```vue + + + +``` + +## Performance Comparison + +| Approach | 100 Items | 1,000 Items | 10,000 Items | +|----------|-----------|-------------|--------------| +| Regular v-for | ~100 DOM nodes | ~1,000 DOM nodes | ~10,000 DOM nodes | +| Virtualized | ~20 DOM nodes | ~20 DOM nodes | ~20 DOM nodes | +| Initial render | Fast | Slow | Very slow / crashes | +| Virtualized render | Fast | Fast | Fast | + +## When NOT to Virtualize + +- Lists under 50 items with simple content +- Lists where all items must be accessible to screen readers simultaneously +- Print layouts where all content must render +- SEO-critical content that must be in initial HTML diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/plugins.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/plugins.md new file mode 100644 index 000000000..190cee822 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/plugins.md @@ -0,0 +1,166 @@ +--- +title: Vue Plugin Best Practices +impact: MEDIUM +impactDescription: Incorrect plugin structure or injection key strategy causes install failures, collisions, and unsafe APIs +type: best-practice +tags: [vue3, plugins, provide-inject, typescript, dependency-injection] +--- + +# Vue Plugin Best Practices + +**Impact: MEDIUM** - Vue plugins should follow the `app.use()` contract, expose explicit capabilities, and use collision-safe injection keys. This keeps plugin setup predictable and composable across large apps. + +## Task List + +- Export plugins as an object with `install()` or as an install function +- Use the `app` instance in `install()` to register components/directives/provides +- Type plugin APIs with `Plugin` (and options tuple types when needed) +- Use symbol keys (prefer `InjectionKey`) for `provide/inject` in plugins +- Add a small typed composable wrapper for required injections to fail fast + +## Structure Plugins for `app.use()` + +A Vue plugin must be either: +- An object with `install(app, options?)` +- A function with the same signature + +**BAD:** +```ts +const notAPlugin = { + doSomething() {} +} + +app.use(notAPlugin) +``` + +**GOOD:** +```ts +import type { App } from 'vue' + +interface PluginOptions { + prefix?: string + debug?: boolean +} + +const myPlugin = { + install(app: App, options: PluginOptions = {}) { + const { prefix = 'my', debug = false } = options + + if (debug) { + console.log('Installing myPlugin with prefix:', prefix) + } + + app.provide('myPlugin', { prefix }) + } +} + +app.use(myPlugin, { prefix: 'custom', debug: true }) +``` + +**GOOD:** +```ts +import type { App } from 'vue' + +function simplePlugin(app: App, options?: { message: string }) { + app.config.globalProperties.$greet = () => options?.message ?? 'Hello!' +} + +app.use(simplePlugin, { message: 'Welcome!' }) +``` + +## Register Capabilities Explicitly in `install()` + +Inside `install()`, wire behavior through Vue application APIs: +- `app.component()` for global components +- `app.directive()` for global directives +- `app.provide()` for injectable services and config +- `app.config.globalProperties` for optional global helpers (sparingly) + +**BAD:** +```ts +const uselessPlugin = { + install(app, options) { + const service = createService(options) + } +} +``` + +**GOOD:** +```ts +const usefulPlugin = { + install(app, options) { + const service = createService(options) + app.provide(serviceKey, service) + } +} +``` + +## Type Plugin Contracts + +Use Vue's `Plugin` type to keep install signatures and options type-safe. + +```ts +import type { App, Plugin } from 'vue' + +interface MyOptions { + apiKey: string +} + +const myPlugin: Plugin<[MyOptions]> = { + install(app: App, options: MyOptions) { + app.provide(apiKeyKey, options.apiKey) + } +} +``` + +## Use Symbol Injection Keys in Plugins + +String keys can collide (`'http'`, `'config'`, `'i18n'`). Use symbol keys with `InjectionKey` so injections are unique and typed. + +**BAD:** +```ts +export default { + install(app) { + app.provide('http', axios) + app.provide('config', appConfig) + } +} +``` + +**GOOD:** +```ts +import type { InjectionKey } from 'vue' +import type { AxiosInstance } from 'axios' + +interface AppConfig { + apiUrl: string + timeout: number +} + +export const httpKey: InjectionKey = Symbol('http') +export const configKey: InjectionKey = Symbol('appConfig') + +export default { + install(app) { + app.provide(httpKey, axios) + app.provide(configKey, { apiUrl: '/api', timeout: 5000 }) + } +} +``` + +## Provide Required Injection Helpers + +Wrap required injections in composables that throw clear setup errors. + +```ts +import { inject } from 'vue' +import { authKey, type AuthService } from '@/injection-keys' + +export function useAuth(): AuthService { + const auth = inject(authKey) + if (!auth) { + throw new Error('Auth plugin not installed. Did you forget app.use(authPlugin)?') + } + return auth +} +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/reactivity.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/reactivity.md new file mode 100644 index 000000000..4cf0ad39c --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/reactivity.md @@ -0,0 +1,344 @@ +--- +title: Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch) +impact: MEDIUM +impactDescription: Clear reactivity choices keep state predictable and reduce unnecessary updates in Vue 3 apps +type: efficiency +tags: [vue3, reactivity, ref, reactive, shallowRef, computed, watch, watchEffect, external-state, best-practice] +--- + +# Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch) + +**Impact: MEDIUM** - Choose the right reactive primitive first, derive with `computed`, and use watchers only for side effects. + +This reference covers the core reactivity decisions for local state, external data, derived values, and effects. + +## Task List + +- Declare reactive state correctly + - Always use `shallowRef()` instead of `ref()` for primitive values + - Choose the correct reactive declaration method for objects/arrays/map/set +- Follow best practices for `reactive` + - Avoid destructuring from `reactive()` directly + - Watch correctly for `reactive` +- Follow best practices for `computed` + - Prefer `computed` over watcher-assigned derived refs + - Keep filtered/sorted derivations out of templates + - Use `computed` for reusable class/style logic + - Keep computed getters pure (no side effects) and put side effects in watchers +- Follow best practices for watchers + - Use `immediate: true` instead of duplicate initial calls + - Clean up async effects for watchers + +## Declare reactive state correctly + +### Always use `shallowRef()` instead of `ref()` for primitive values (string, number, boolean, null, etc.) for better performance. + +**Incorrect:** +```ts +import { ref } from 'vue' +const count = ref(0) +``` + +**Correct:** +```ts +import { shallowRef } from 'vue' +const count = shallowRef(0) +``` + +### Choose the correct reactive declaration method for objects/arrays/map/set + +Use `ref()` when you often **replace the entire value** (`state.value = newObj`) and still want deep reactivity inside it, usually used for: + +- Frequently reassigned state (replace fetched object/list, reset to defaults, switch presets). +- Composable return values where updates happen mostly via `.value` reassignment. + +Use `reactive()` when you mainly **mutate properties** and full replacement is uncommon, usually used for: + +- “Single state object” patterns (stores/forms): `state.count++`, `state.items.push(...)`, `state.user.name = ...`. +- Situations where you want to avoid `.value` and update nested fields in place. + +```ts +import { reactive } from 'vue' + +const state = reactive({ + count: 0, + user: { name: 'Alice', age: 30 } +}) + +state.count++ // ✅ reactive +state.user.age = 31 // ✅ reactive +// ❌ avoid replacing the reactive object reference: +// state = reactive({ count: 1 }) +``` + +Use `shallowRef()` when the value is **opaque / should not be proxied** (class instances, external library objects, very large nested data) and you only want updates to trigger when you **replace** `state.value` (no deep tracking), usually used for: + +- Storing external instances/handles (SDK clients, class instances) without Vue proxying internals. +- Large data where you update by replacing the root reference (immutable-style updates). + +```ts +import { shallowRef } from 'vue' + +const user = shallowRef({ name: 'Alice', age: 30 }) + +user.value.age = 31 // ❌ not reactive +user.value = { name: 'Bob', age: 25 } // ✅ triggers update +``` + +Use `shallowReactive()` when you want **only top-level properties** reactive; nested objects remain raw, usually used for: + +- Container objects where only top-level keys change and nested payloads should stay unmanaged/unproxied. +- Mixed structures where Vue tracks the wrapper object, but not deeply nested or foreign objects. + +```ts +import { shallowReactive } from 'vue' + +const state = shallowReactive({ + count: 0, + user: { name: 'Alice', age: 30 } +}) + +state.count++ // ✅ reactive +state.user.age = 31 // ❌ not reactive +``` + +## Best practices for `reactive` + +### Avoid destructuring from `reactive()` directly + +**BAD:** + +```ts +import { reactive } from 'vue' + +const state = reactive({ count: 0 }) +const { count } = state // ❌ disconnected from reactivity +``` + +### Watch correctly for reactive + +**BAD:** + +passing a non-getter value into `watch()` + +```ts +import { reactive, watch } from 'vue' + +const state = reactive({ count: 0 }) + +// ❌ watch expects a getter, ref, reactive object, or array of these +watch(state.count, () => { /* ... */ }) +``` + +**GOOD:** + +preserve reactivity with `toRefs()` and use a getter for `watch()` + +```ts +import { reactive, toRefs, watch } from 'vue' + +const state = reactive({ count: 0 }) +const { count } = toRefs(state) // ✅ count is a ref + +watch(count, () => { /* ... */ }) // ✅ +watch(() => state.count, () => { /* ... */ }) // ✅ +``` + +## Best practices for `computed` + +### Prefer `computed` over watcher-assigned derived refs + +**BAD:** +```ts +import { ref, watchEffect } from 'vue' + +const items = ref([{ price: 10 }, { price: 20 }]) +const total = ref(0) + +watchEffect(() => { + total.value = items.value.reduce((sum, item) => sum + item.price, 0) +}) +``` + +**GOOD:** +```ts +import { ref, computed } from 'vue' + +const items = ref([{ price: 10 }, { price: 20 }]) +const total = computed(() => + items.value.reduce((sum, item) => sum + item.price, 0) +) +``` + +### Keep filtered/sorted derivations out of templates + +**BAD:** +```vue + + + +``` + +**GOOD:** +```vue + + + +``` + +### Use `computed` for reusable class/style logic + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + + + +``` + +### Keep computed getters pure (no side effects) and put side effects in watchers instead + +A computed getter should only derive a value. No mutation, no API calls, no storage writes, no event emits. +([Reference](https://vuejs.org/guide/essentials/computed.html#best-practices)) + +**BAD:** + +side effects inside computed + +```ts +const count = ref(0) + +const doubled = computed(() => { + // ❌ side effect + if (count.value > 10) console.warn('Too big!') + return count.value * 2 +}) +``` + +**GOOD:** + +pure computed + `watch()` for side effects + +```ts +const count = ref(0) +const doubled = computed(() => count.value * 2) + +watch(count, (value) => { + if (value > 10) console.warn('Too big!') +}) +``` + +## Best practices for watchers + +### Use `immediate: true` instead of duplicate initial calls + +**BAD:** +```ts +import { ref, watch, onMounted } from 'vue' + +const userId = ref(1) + +function loadUser(id) { + // ... +} + +onMounted(() => loadUser(userId.value)) +watch(userId, (id) => loadUser(id)) +``` + +**GOOD:** +```ts +import { ref, watch } from 'vue' + +const userId = ref(1) + +watch( + userId, + (id) => loadUser(id), + { immediate: true } +) +``` + +### Clean up async effects for watchers + +When reacting to rapid changes (search boxes, filters), cancel the previous request. + +**GOOD:** + +```ts +const query = ref('') +const results = ref([]) + +watch(query, async (q, _prev, onCleanup) => { + const controller = new AbortController() + onCleanup(() => controller.abort()) + + const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { + signal: controller.signal, + }) + + results.value = await res.json() +}) +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/render-functions.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/render-functions.md new file mode 100644 index 000000000..b64942c57 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/render-functions.md @@ -0,0 +1,201 @@ +--- +title: Render Function Patterns and Performance +impact: MEDIUM +impactDescription: Render functions require explicit patterns for lists, events, v-model, and performance to stay correct and maintainable +type: best-practice +tags: [vue3, render-function, h, v-model, directives, performance, jsx] +--- + +# Render Function Patterns and Performance + +**Impact: MEDIUM** - Render functions are powerful but opt out of template compiler optimizations. Use them intentionally and apply the key patterns below to keep output correct and performant. + +## Task List + +- Prefer templates; use render functions only when templates cannot express the logic +- Always add stable keys when rendering lists with `h()`/JSX +- Use `withModifiers` / `withKeys` for event modifiers +- Implement `v-model` via `modelValue` + `onUpdate:modelValue` +- Apply custom directives with `withDirectives` +- Use functional components for stateless presentational UI + +## Prefer templates over render functions + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + + + +``` + +## Always add keys for list rendering + +**BAD:** +```javascript +import { h, ref } from 'vue' + +export default { + setup() { + const items = ref([{ id: 1, name: 'Apple' }]) + + return () => h('ul', + items.value.map(item => h('li', item.name)) + ) + } +} +``` + +**GOOD:** +```javascript +import { h, ref } from 'vue' + +export default { + setup() { + const items = ref([{ id: 1, name: 'Apple' }]) + + return () => h('ul', + items.value.map(item => h('li', { key: item.id }, item.name)) + ) + } +} +``` + +## Use `withModifiers` / `withKeys` for event modifiers + +**BAD:** +```javascript +import { h } from 'vue' + +export default { + setup() { + const handleClick = (e) => { + e.stopPropagation() + e.preventDefault() + } + + return () => h('button', { onClick: handleClick }, 'Click') + } +} +``` + +**GOOD:** +```javascript +import { h, withModifiers, withKeys } from 'vue' + +export default { + setup() { + const handleClick = () => {} + const handleEnter = () => {} + + return () => h('div', [ + h('button', { + onClick: withModifiers(handleClick, ['stop', 'prevent']) + }, 'Click'), + h('input', { + onKeyup: withKeys(handleEnter, ['enter']) + }) + ]) + } +} +``` + +## Implement `v-model` explicitly + +**BAD:** +```javascript +import { h, ref } from 'vue' +import CustomInput from './CustomInput.vue' + +export default { + setup() { + const text = ref('') + return () => h(CustomInput, { modelValue: text.value }) + } +} +``` + +**GOOD:** +```javascript +import { h, ref } from 'vue' +import CustomInput from './CustomInput.vue' + +export default { + setup() { + const text = ref('') + return () => h(CustomInput, { + modelValue: text.value, + 'onUpdate:modelValue': (value) => { text.value = value } + }) + } +} +``` + +## Use `withDirectives` for custom directives + +**BAD:** +```javascript +import { h } from 'vue' + +const vFocus = { mounted: (el) => el.focus() } + +export default { + setup() { + return () => h('input', { 'v-focus': true }) + } +} +``` + +**GOOD:** +```javascript +import { h, withDirectives } from 'vue' + +const vFocus = { mounted: (el) => el.focus() } + +export default { + setup() { + return () => withDirectives(h('input'), [[vFocus]]) + } +} +``` + +## Prefer functional components for stateless UI + +**BAD:** +```javascript +import { h } from 'vue' + +export default { + setup() { + return () => h('span', { class: 'badge' }, 'New') + } +} +``` + +**GOOD:** +```javascript +import { h } from 'vue' + +function Badge(props, { slots }) { + return h('span', { class: 'badge' }, slots.default?.()) +} + +Badge.props = ['variant'] + +export default Badge +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/sfc.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/sfc.md new file mode 100644 index 000000000..d1c3981c7 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/sfc.md @@ -0,0 +1,310 @@ +--- +title: Single-File Component Structure, Styling, and Template Patterns +impact: MEDIUM +impactDescription: Consistent SFC structure and styling choices improve maintainability, tooling support, and render performance +type: best-practice +tags: [vue3, sfc, scoped-css, styles, build-tools, performance, template, v-html, v-for, computed, v-if, v-show] +--- + +# Single-File Component Structure, Styling, and Template Patterns + +**Impact: MEDIUM** - Using SFCs with consistent structure and performant styling keeps components easier to maintain and avoids unnecessary render overhead. + +## Task List + +- Use `.vue` SFCs instead of separate `.js`/`.ts` and `.css` files for components +- Colocate template, script, and styles in the same SFC by default +- Use PascalCase for component names in templates and filenames +- Prefer component-scoped styles +- Prefer class selectors (not element selectors) in scoped CSS for performance +- Access DOM / component refs with `useTemplateRef()` in Vue 3.5+ +- Use camelCase keys in `:style` bindings for consistency and IDE support +- Use `v-for` and `v-if` correctly +- Never use `v-html` with untrusted/user-provided content +- Choose `v-if` vs `v-show` based on toggle frequency and initial render cost + +## Colocate template, script, and styles + +**BAD:** +``` +components/ +├── UserCard.vue +├── UserCard.js +└── UserCard.css +``` + +**GOOD:** +```vue + + + + + + +``` + +## Use PascalCase for component names + +**BAD:** +```vue + + + +``` + +**GOOD:** +```vue + + + +``` + +## Best practices for ` +``` + +**GOOD:** + +```vue + +``` + +**GOOD:** + +```css +/* src/assets/main.css */ +/* ✅ resets, tokens, typography, app-wide rules */ +:root { --radius: 999px; } +``` + +### Use class selectors in scoped CSS + +**BAD:** +```vue + + + +``` + +**GOOD:** +```vue + + + +``` + +## Access DOM / component refs with `useTemplateRef()` + +For Vue 3.5+: use `useTemplateRef()` to access template refs. + +```vue + + + +``` + +## Use camelCase in `:style` bindings + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` + +## Use `v-for` and `v-if` correctly + +### Always provide a stable `:key` + +- Prefer primitive keys (`string | number`). +- Avoid using objects as keys. + +**GOOD:** + +```vue +
  • + +
  • +``` + +### Avoid `v-if` and `v-for` on the same element + +It leads to unclear intent and unnecessary work. +([Reference](https://vuejs.org/guide/essentials/list.html#v-for-with-v-if)) + +**To filter items** +**BAD:** + +```vue +
  • + {{ user.name }} +
  • +``` + +**GOOD:** + +```vue + + + +``` + +**To conditionally show/hide the entire list** +**GOOD:** + +```vue +
      +
    • + {{ user.name }} +
    • +
    +``` + +## Never render untrusted HTML with `v-html` + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + + + +``` + +## Choose `v-if` vs `v-show` by toggle behavior + +**BAD:** +```vue + +``` + +**GOOD:** +```vue + +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/state-management.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/state-management.md new file mode 100644 index 000000000..02423ab24 --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/state-management.md @@ -0,0 +1,135 @@ +--- +title: State Management Strategy +impact: HIGH +impactDescription: Choosing the wrong store pattern can cause SSR request leaks, brittle mutation flows, and poor scaling +type: best-practice +tags: [vue3, state-management, pinia, composables, ssr, vueuse] +--- + +# State Management Strategy + +**Impact: HIGH** - Use the lightest state solution that fits your app architecture. SPA-only apps can use lightweight global composables, while SSR/Nuxt apps should default to Pinia for request-safe isolation and predictable tooling. + +## Task List + +- Keep state local first, then promote to shared/global only when needed +- Use singleton composables only in non-SSR applications +- Expose global state as readonly and mutate through explicit actions +- Prefer Pinia for SSR/Nuxt, large apps, and advanced debugging/plugin needs +- Avoid exporting mutable module-level reactive state directly + +## Choose the Lightest Store Approach + +- **Feature composable:** Default for reusable logic with local/feature-level state. +- **Singleton composable or VueUse `createGlobalState`:** Small non-SSR apps needing shared app state. +- **Pinia:** SSR/Nuxt apps, medium-to-large apps, and cases requiring DevTools, plugins, or action tracing. + +## Avoid Exporting Mutable Module State + +**BAD:** +```ts +// store/cart.ts +import { reactive } from 'vue' + +export const cart = reactive({ + items: [] as Array<{ id: string; qty: number }> +}) +``` + +**GOOD:** +```ts +// composables/useCartStore.ts +import { reactive, readonly } from 'vue' + +let _store: ReturnType | null = null + +function createCartStore() { + const state = reactive({ + items: [] as Array<{ id: string; qty: number }> + }) + + function addItem(id: string, qty = 1) { + const existing = state.items.find((item) => item.id === id) + if (existing) { + existing.qty += qty + return + } + state.items.push({ id, qty }) + } + + return { + state: readonly(state), + addItem + } +} + +export function useCartStore() { + if (!_store) _store = createCartStore() + return _store +} +``` + +## Do Not Use Runtime Singletons in SSR + +Module singletons live for the runtime lifetime. In SSR this can leak state between requests. + +**BAD:** +```ts +// shared singleton reused across requests +const cartStore = useCartStore() + +export function useServerCart() { + return cartStore +} +``` + +**GOOD:** + +> `pinia` dependency required. + +```ts +// stores/cart.ts +import { defineStore } from 'pinia' + +export const useCartStore = defineStore('cart', { + state: () => ({ + items: [] as Array<{ id: string; qty: number }> + }), + actions: { + addItem(id: string, qty = 1) { + const existing = this.items.find((item) => item.id === id) + if (existing) { + existing.qty += qty + return + } + this.items.push({ id, qty }) + } + } +}) +``` + +## Use `createGlobalState` for Small SPA Global State + +> `@vueuse/core` dependency required. + +If the app is non-SSR and already uses VueUse, `createGlobalState` removes singleton boilerplate. + +```ts +import { createGlobalState } from '@vueuse/core' +import { computed, ref } from 'vue' + +export const useAuthState = createGlobalState(() => { + const token = ref(null) + const isAuthenticated = computed(() => token.value !== null) + + function setToken(next: string | null) { + token.value = next + } + + return { + token, + isAuthenticated, + setToken + } +}) +``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/updated-hook-performance.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/updated-hook-performance.md new file mode 100644 index 000000000..6375e862b --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/updated-hook-performance.md @@ -0,0 +1,187 @@ +--- +title: Avoid Expensive Operations in Updated Hook +impact: MEDIUM +impactDescription: Heavy computations in updated hook cause performance bottlenecks and potential infinite loops +type: capability +tags: [vue3, vue2, lifecycle, updated, performance, optimization, reactivity] +--- + +# Avoid Expensive Operations in Updated Hook + +**Impact: MEDIUM** - The `updated` hook runs after every reactive state change that causes a re-render. Placing expensive operations, API calls, or state mutations here can cause severe performance degradation, infinite loops, and dropped frames below the optimal 60fps threshold. + +Use `updated`/`onUpdated` sparingly for post-DOM-update operations that cannot be handled by watchers or computed properties. For most reactive data handling, prefer watchers (`watch`/`watchEffect`) which provide more control over what triggers the callback. + +## Task List + +- Never perform API calls in updated hook +- Never mutate reactive state inside updated (causes infinite loops) +- Use conditional checks to verify updates are relevant before acting +- Prefer `watch` or `watchEffect` for reacting to specific data changes +- Use throttling/debouncing if updated operations are expensive +- Reserve updated for low-level DOM synchronization tasks + +**BAD:** +```javascript +// BAD: API call in updated - fires on every re-render +export default { + data() { + return { items: [], lastUpdate: null } + }, + updated() { + // This runs after every single state change! + fetch('/api/sync', { + method: 'POST', + body: JSON.stringify(this.items) + }) + } +} +``` + +```javascript +// BAD: State mutation in updated - infinite loop +export default { + data() { + return { renderCount: 0 } + }, + updated() { + // This causes another update, which triggers updated again! + this.renderCount++ // Infinite loop + } +} +``` + +```javascript +// BAD: Heavy computation on every update +export default { + updated() { + // Expensive operation runs on every keystroke, every state change + this.processedData = this.heavyComputation(this.rawData) + this.analytics = this.calculateMetrics(this.allData) + } +} +``` + +**GOOD:** +```javascript +import debounce from 'lodash-es/debounce' + +// GOOD: Use watcher for specific data changes +export default { + data() { + return { items: [] } + }, + watch: { + // Only fires when items actually changes + items: { + handler(newItems) { + this.syncToServer(newItems) + }, + deep: true + } + }, + methods: { + syncToServer: debounce(function(items) { + fetch('/api/sync', { + method: 'POST', + body: JSON.stringify(items) + }) + }, 500) + } +} +``` + +```vue + + +``` + +```javascript +// GOOD: Conditional check in updated hook +export default { + data() { + return { + content: '', + lastSyncedContent: '' + } + }, + updated() { + // Only act if specific condition is met + if (this.content !== this.lastSyncedContent) { + this.syncContent() + this.lastSyncedContent = this.content + } + }, + methods: { + syncContent: debounce(function() { + // Sync logic + }, 300) + } +} +``` + +## Valid Use Cases for Updated Hook + +```javascript +// GOOD: Low-level DOM synchronization +export default { + updated() { + // Sync third-party library with Vue's DOM + this.thirdPartyWidget.refresh() + + // Update scroll position after content change + this.$nextTick(() => { + this.maintainScrollPosition() + }) + } +} +``` + +## Prefer Computed Properties for Derived Data + +```javascript +// BAD: Calculating derived data in updated +export default { + data() { + return { numbers: [1, 2, 3, 4, 5] } + }, + updated() { + this.sum = this.numbers.reduce((a, b) => a + b, 0) // Causes another update! + } +} + +// GOOD: Use computed property instead +export default { + data() { + return { numbers: [1, 2, 3, 4, 5] } + }, + computed: { + sum() { + return this.numbers.reduce((a, b) => a + b, 0) + } + } +} +``` diff --git a/packages/kit/src/message/test/fixtures/skills/weather/SKILL.md b/packages/kit/src/message/test/fixtures/skills/weather/SKILL.md new file mode 100644 index 000000000..7901e12ec --- /dev/null +++ b/packages/kit/src/message/test/fixtures/skills/weather/SKILL.md @@ -0,0 +1,129 @@ +--- +name: weather +description: "Get current weather, rain, temperature, and forecasts for locations or travel planning." +homepage: https://wttr.in/:help +metadata: + { + "openclaw": + { + "emoji": "☔", + "requires": { "bins": ["curl"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "curl", + "bins": ["curl"], + "label": "Install curl (brew)", + }, + ], + }, + } +--- + +# Weather Skill + +Get current weather conditions and forecasts. + +## When to Use + +✅ **USE this skill when:** + +- "What's the weather?" +- "Will it rain today/tomorrow?" +- "Temperature in [city]" +- "Weather forecast for the week" +- Travel planning weather checks + +## When NOT to Use + +❌ **DON'T use this skill when:** + +- Historical weather data → use weather archives/APIs +- Climate analysis or trends → use specialized data sources +- Hyper-local microclimate data → use local sensors +- Severe weather alerts → check official NWS sources +- Aviation/marine weather → use specialized services (METAR, etc.) + +## Location + +Always include a city, region, or airport code in weather queries. + +## Commands + +### Current Weather + +```bash +# One-line summary +curl "wttr.in/London?format=3" + +# Detailed current conditions +curl "wttr.in/London?0" + +# Specific city +curl "wttr.in/New+York?format=3" +``` + +### Forecasts + +```bash +# 3-day forecast +curl "wttr.in/London" + +# Week forecast +curl "wttr.in/London?format=v2" + +# Specific day (0=today, 1=tomorrow, 2=day after) +curl "wttr.in/London?1" +``` + +### Format Options + +```bash +# One-liner +curl "wttr.in/London?format=%l:+%c+%t+%w" + +# JSON output +curl "wttr.in/London?format=j1" + +# PNG image +curl "wttr.in/London.png" +``` + +### Format Codes + +- `%c` — Weather condition emoji +- `%t` — Temperature +- `%f` — "Feels like" +- `%w` — Wind +- `%h` — Humidity +- `%p` — Precipitation +- `%l` — Location + +## Quick Responses + +**"What's the weather?"** + +```bash +curl -s "wttr.in/London?format=%l:+%c+%t+(feels+like+%f),+%w+wind,+%h+humidity" +``` + +**"Will it rain?"** + +```bash +curl -s "wttr.in/London?format=%l:+%c+%p" +``` + +**"Weekend forecast"** + +```bash +curl "wttr.in/London?format=v2" +``` + +## Notes + +- No API key needed (uses wttr.in) +- Rate limited; don't spam requests +- Works for most global cities +- Supports airport codes: `curl wttr.in/ORD` diff --git a/packages/kit/src/message/test/mockResponseProvider.ts b/packages/kit/src/message/test/mockResponseProvider.ts index 7394521e3..3c6c56229 100644 --- a/packages/kit/src/message/test/mockResponseProvider.ts +++ b/packages/kit/src/message/test/mockResponseProvider.ts @@ -1,4 +1,4 @@ -import type { ChatCompletionChunk } from 'openai/resources/index' +import type { ChatCompletionChunk } from 'openai/resources' import type { ResponseProvider } from '../types' import { AbortError } from '../utils' diff --git a/packages/kit/src/message/test/skillLoader.test.ts b/packages/kit/src/message/test/skillLoader.test.ts new file mode 100644 index 000000000..a17fbb6d4 --- /dev/null +++ b/packages/kit/src/message/test/skillLoader.test.ts @@ -0,0 +1,89 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { loadSkillFilesFromFs } from '../skills/fsSkillLoader' +import { SkillLoader } from '../skills/skillLoader' + +describe('SkillLoader', () => { + it('loads weather skill directory as SkillDefinition', async () => { + const skillDirectory = fileURLToPath(new URL('./fixtures/skills/weather', import.meta.url)) + const files = await loadSkillFilesFromFs(skillDirectory) + const loadedSkill = new SkillLoader().load(files) + const { skill } = loadedSkill + + expect(skill.name).toBe('weather') + expect(skill.description).toContain('weather') + expect(skill.instructions).toContain('# Weather Skill') + expect(skill.metadata?.homepage).toBe('https://wttr.in/:help') + expect(loadedSkill.warnings).toEqual([]) + }) + + it('loads multi-file skill references as files', async () => { + const skillDirectory = fileURLToPath(new URL('./fixtures/skills/vue-best-practices', import.meta.url)) + const files = await loadSkillFilesFromFs(skillDirectory) + const loadedSkill = new SkillLoader().load(files) + const { skill } = loadedSkill + + expect(skill.name).toBe('vue-best-practices') + expect(skill.description).toContain('Vue.js tasks') + expect(skill.instructions).toContain('# Vue Best Practices Workflow') + expect(skill.metadata).toMatchObject({ + author: 'github.com/vuejs-ai', + version: '18.0.0', + }) + expect(skill.files).toBeDefined() + expect(skill.files).toHaveLength(files.length - 1) + expect(skill.files?.map((file) => file.id)).toEqual( + expect.arrayContaining([ + 'references/reactivity.md', + 'references/sfc.md', + 'references/component-data-flow.md', + 'references/composables.md', + ]), + ) + expect(skill.files?.find((file) => file.id === 'references/reactivity.md')).toMatchObject({ + path: 'references/reactivity.md', + kind: 'text', + content: expect.stringContaining('# Reactivity'), + }) + expect(loadedSkill.warnings).toEqual([]) + }) + + it('keeps binary files as skill files', () => { + const image = new Uint8Array([1, 2, 3]) + const loadedSkill = new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'text', + content: [ + '---', + 'name: binary-skill', + 'description: Skill with binary assets', + '---', + '', + '# Binary Skill', + ].join('\n'), + }, + { + path: 'assets/icon.png', + kind: 'binary', + content: image, + mimeType: 'image/png', + size: image.byteLength, + lastModified: 123, + }, + ]) + + expect(loadedSkill.skill.files).toEqual([ + { + id: 'assets/icon.png', + path: 'assets/icon.png', + kind: 'binary', + content: image, + mimeType: 'image/png', + size: 3, + lastModified: 123, + }, + ]) + expect(loadedSkill.warnings).toEqual([]) + }) +}) diff --git a/packages/kit/src/message/test/skillPlugin.test.ts b/packages/kit/src/message/test/skillPlugin.test.ts new file mode 100644 index 000000000..95a2b7c41 --- /dev/null +++ b/packages/kit/src/message/test/skillPlugin.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it, vi } from 'vitest' +import { createNativeMessageAdapter } from '../adapters/native' +import { createMessageEngine } from '../core/engine' +import { lengthPlugin, skillPlugin, thinkingPlugin, toolPlugin } from '../plugins' +import type { CreateMessageEngineOptions, MessageRequestBody } from '../types' +import { mockResponseProvider } from './mockResponseProvider' + +const silentDefaultPlugins = [thinkingPlugin({ disabled: true }), lengthPlugin({ disabled: true })] + +const createTestMessageEngine = (options: CreateMessageEngineOptions) => + createMessageEngine(createNativeMessageAdapter(), options) + +describe('skillPlugin', () => { + it('injects active skill instructions and tools before request', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + const skillTool = { + type: 'function', + function: { + name: 'get_weather', + description: 'Get weather', + parameters: { + type: 'object', + properties: {}, + }, + }, + } as const + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + skills: [ + { + name: 'weather', + description: 'Weather skill', + instructions: 'Use wttr.in for weather requests.', + tools: [skillTool], + }, + ], + getActiveSkills: () => ['weather'], + }), + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + responseProvider, + }) + + await engine.sendMessage('weather in London') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Use wttr.in for weather requests.'), + }) + expect(requestBody.messages[1]).toMatchObject({ role: 'user', content: 'weather in London' }) + expect(requestBody.tools).toEqual([skillTool]) + }) + + it('resolves dynamic skill instructions and tools with runtime context', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + skills: [ + { + name: 'dynamic', + description: 'Dynamic skill', + instructions: ({ skill, activeSkills }) => `${skill.name}:${activeSkills.length}`, + tools: ({ skill }) => [ + { + type: 'function', + function: { + name: `${skill.name}_tool`, + description: 'Dynamic tool', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + ], + }, + ], + getActiveSkills: () => ['dynamic'], + }), + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + responseProvider, + }) + + await engine.sendMessage('run dynamic skill') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.messages[0].content).toContain('dynamic:1') + expect(requestBody.tools![0].function.name).toBe('dynamic_tool') + }) + + it('throws duplicate tool names through toolPlugin when skill tools conflict', async () => { + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + skills: [ + { + name: 'duplicate-skill', + description: 'Duplicate skill', + tools: [ + { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Duplicated skill tool', + }, + }, + ], + }, + ], + getActiveSkills: () => ['duplicate-skill'], + }), + toolPlugin({ + getTools: async () => [ + { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Duplicated user tool', + }, + }, + ], + callTool: async () => 'fallback', + }), + ], + responseProvider: async () => { + throw new Error('responseProvider should not be called') + }, + }) + + await expect(engine.sendMessage('trigger duplicate tool')).rejects.toThrow( + 'Duplicate tool name "duplicate_tool" detected.', + ) + }) + + it('exposes built-in skill file runtime tools when active skills have files', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + skills: [ + { + name: 'vue-best-practices', + description: 'Vue skill', + instructions: 'Follow Vue best practices.', + files: [ + { + id: 'references/reactivity.md', + path: 'references/reactivity.md', + kind: 'text', + content: '# Reactivity', + mimeType: 'text/markdown', + }, + ], + }, + ], + getActiveSkills: () => ['vue-best-practices'], + }), + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + responseProvider, + }) + + await engine.sendMessage('review this Vue component') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['list_skill_files', 'read_skill_file']) + expect(requestBody.tools?.[0].function.parameters).toMatchObject({ + type: 'object', + properties: { + skillName: expect.objectContaining({ type: 'string' }), + }, + }) + expect(requestBody.tools?.[1].function.parameters).toMatchObject({ + type: 'object', + required: ['skillName', 'path'], + properties: { + skillName: expect.objectContaining({ type: 'string' }), + path: expect.objectContaining({ type: 'string' }), + }, + }) + }) + + it('does not expose built-in skill file runtime tools when active skills have no files', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + skills: [ + { + name: 'plain', + description: 'Plain skill', + instructions: 'No files here.', + }, + ], + getActiveSkills: () => ['plain'], + }), + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + responseProvider, + }) + + await engine.sendMessage('run plain skill') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.tools).toBeUndefined() + }) + + it('executes built-in skill file runtime tools from turn state', async () => { + const responseProvider = vi.fn(async (requestBody: MessageRequestBody) => { + const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') + + if (!hasToolResult) { + return { + id: 'tool-call', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { + name: 'read_skill_file', + arguments: JSON.stringify({ + skillName: 'vue-best-practices', + path: 'references/reactivity.md', + }), + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + } + } + + expect(JSON.parse(requestBody.messages.at(-1)?.content as string)).toMatchObject({ + file: { + skillName: 'vue-best-practices', + path: 'references/reactivity.md', + kind: 'text', + }, + content: '# Reactivity', + }) + + return { + id: 'final-answer', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + } + }) + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + toolPlugin({ + getTools: async () => [], + callTool: async () => { + throw new Error('fallback should not run') + }, + }), + skillPlugin({ + skills: [ + { + name: 'vue-best-practices', + description: 'Vue skill', + instructions: 'Follow Vue best practices.', + files: [ + { + id: 'references/reactivity.md', + path: 'references/reactivity.md', + kind: 'text', + content: '# Reactivity', + mimeType: 'text/markdown', + }, + ], + }, + ], + getActiveSkills: () => ['vue-best-practices'], + }), + ], + responseProvider, + }) + + await engine.sendMessage('read skill file') + + expect(responseProvider).toHaveBeenCalledTimes(2) + expect(engine.getState().messages.at(-1)).toMatchObject({ + role: 'assistant', + content: 'done', + }) + }) +}) diff --git a/packages/kit/src/message/test/toolPlugin.test.ts b/packages/kit/src/message/test/toolPlugin.test.ts new file mode 100644 index 000000000..816e13285 --- /dev/null +++ b/packages/kit/src/message/test/toolPlugin.test.ts @@ -0,0 +1,248 @@ +import type { ChatCompletion } from 'openai/resources' +import { describe, expect, it, vi } from 'vitest' +import { createNativeMessageAdapter } from '../adapters/native' +import { createMessageEngine } from '../core/engine' +import { lengthPlugin, thinkingPlugin, toolPlugin, type RuntimeTool, type ToolProvider } from '../plugins' +import type { CreateMessageEngineOptions, MessageEnginePlugin, ResponseProvider } from '../types' + +const silentDefaultPlugins = [thinkingPlugin({ disabled: true }), lengthPlugin({ disabled: true })] + +const createTestMessageEngine = (options: CreateMessageEngineOptions) => + createMessageEngine(createNativeMessageAdapter(), options) + +describe('toolPlugin', () => { + it('injects and executes runtime tools before falling back to callTool', async () => { + const runtimeCall = vi.fn(() => ({ result: 'runtime-result' })) + const fallbackCall = vi.fn() + const runtimeTool: RuntimeTool = { + tool: { + type: 'function', + function: { + name: 'runtime_lookup', + description: 'Runtime lookup', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + }, + required: ['query'], + }, + }, + }, + handler: runtimeCall, + } + const responseProvider = vi.fn(async (requestBody) => { + const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') + + if (!hasToolResult) { + expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['runtime_lookup']) + return { + id: 'tool-call', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { + name: 'runtime_lookup', + arguments: JSON.stringify({ query: 'vue' }), + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + } as ChatCompletion + } + + expect(requestBody.messages.at(-1)).toMatchObject({ + role: 'tool', + tool_call_id: 'call-1', + content: JSON.stringify({ result: 'runtime-result' }), + }) + return { + id: 'final-answer', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + } as ChatCompletion + }) + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + toolPlugin({ + getTools: async () => [runtimeTool], + callTool: fallbackCall, + }), + ], + responseProvider, + }) + + await engine.sendMessage('lookup vue') + + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'call-1', + function: expect.objectContaining({ name: 'runtime_lookup' }), + }), + expect.objectContaining({ + toolMessage: expect.objectContaining({ role: 'tool' }), + }), + ) + expect(fallbackCall).not.toHaveBeenCalled() + expect(responseProvider).toHaveBeenCalledTimes(2) + expect(engine.getState().messages.at(-1)).toMatchObject({ + role: 'assistant', + content: 'done', + }) + }) + + it('throws when tool names are duplicated', async () => { + const runtimeTool: RuntimeTool = { + tool: { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Runtime duplicate', + }, + }, + handler: () => 'runtime', + } + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + toolPlugin({ + getTools: async () => [ + { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Schema duplicate', + }, + }, + runtimeTool, + ], + callTool: async () => 'fallback', + }), + ], + responseProvider: async () => { + throw new Error('responseProvider should not be called') + }, + }) + + await expect(engine.sendMessage('trigger duplicate tools')).rejects.toThrow( + 'Duplicate tool name "duplicate_tool" detected.', + ) + }) + + it('throws when provided tools conflict with existing request tools', async () => { + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + { + name: 'existing-tools', + onBeforeRequest: (context) => { + context.requestBody.tools = [ + { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Existing request tool', + }, + }, + ] + }, + }, + toolPlugin({ + getTools: async () => [ + { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Provided tool', + }, + }, + ], + callTool: async () => 'fallback', + }), + ], + responseProvider: async () => { + throw new Error('responseProvider should not be called') + }, + }) + + await expect(engine.sendMessage('trigger duplicate existing tool')).rejects.toThrow( + 'Duplicate tool name "duplicate_tool" detected.', + ) + }) + + it('loads tools provided by other plugins', async () => { + const responseProvider = vi.fn(async () => { + return { + id: 'answer', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + } as ChatCompletion + }) + + const providerPlugin: MessageEnginePlugin & ToolProvider = { + name: 'provider', + provideTools: async () => [ + { + type: 'function', + function: { + name: 'provided_tool', + description: 'Provided by another plugin', + }, + }, + ], + } + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + providerPlugin, + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + responseProvider, + }) + + await engine.sendMessage('use provided tool') + + expect(responseProvider.mock.calls[0]?.[0].tools?.map((tool) => tool.function.name)).toEqual(['provided_tool']) + }) +}) diff --git a/packages/kit/src/message/types.ts b/packages/kit/src/message/types.ts index 6ef9a9baa..92c34220f 100644 --- a/packages/kit/src/message/types.ts +++ b/packages/kit/src/message/types.ts @@ -2,9 +2,10 @@ import { ChatCompletion, ChatCompletionChunk, + ChatCompletionFunctionTool, ChatCompletionMessageParam, ChatCompletionMessageToolCall, -} from 'openai/resources/index' +} from 'openai/resources' import { MaybePromise } from '../types' export type DeepReadonly = T extends (...args: any[]) => any @@ -32,6 +33,7 @@ export type ChatMessage< export interface MessageRequestBody { messages: Array + tools?: Array [key: string]: any } @@ -128,6 +130,12 @@ export interface BasePluginContext { mutate: MutateMessageStateFn abortSignal: AbortSignal currentTurn: ChatMessage[] + /** + * 当前 engine 中已注册的插件列表。 + * + * 插件可基于该列表发现其他插件暴露的轻量协议,例如 toolPlugin 收集 provideTools。 + */ + plugins: readonly MessageEnginePlugin[] customContext: Record setRequestState: (state: RequestState, processingState?: RequestProcessingState) => void setCustomContext: (data: Record) => void diff --git a/packages/kit/src/message/utils.ts b/packages/kit/src/message/utils.ts index 5ab1937c2..ed0679f59 100644 --- a/packages/kit/src/message/utils.ts +++ b/packages/kit/src/message/utils.ts @@ -84,7 +84,7 @@ export function omitFields, K extends keyof T> } export async function* normalizeToAsyncGenerator( - result: Promise | AsyncGenerator | Promise>, + result: T | Promise | AsyncGenerator | Promise>, ): AsyncGenerator { // 情况 1:是 async generator 或 sync generator if (isAsyncGenerator(result)) { diff --git a/packages/kit/src/vue/message/mockResponseProvider.ts b/packages/kit/src/vue/message/mockResponseProvider.ts index 6d2275032..03eb1c75d 100644 --- a/packages/kit/src/vue/message/mockResponseProvider.ts +++ b/packages/kit/src/vue/message/mockResponseProvider.ts @@ -1,4 +1,4 @@ -import type { ChatCompletionChunk } from 'openai/resources/index' +import type { ChatCompletionChunk } from 'openai/resources' import type { ToolCall } from '../../types' import type { MessageRequestBody, ResponseProvider } from './types' diff --git a/packages/kit/src/vue/message/plugins/toolPlugin.ts b/packages/kit/src/vue/message/plugins/toolPlugin.ts index 564bfb62e..f3132ab09 100644 --- a/packages/kit/src/vue/message/plugins/toolPlugin.ts +++ b/packages/kit/src/vue/message/plugins/toolPlugin.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { toolPlugin as createCoreToolPlugin } from '../../../message/plugins' +import type { ToolProviderItem } from '../../../message/plugins' import { normalizeToAsyncGenerator } from '../../../message/utils' import { ChatMessage, ToolCall } from '../../../types' import type { VueMessagePluginRuntime } from '../types.internal' -import { BasePluginContext, Tool, UseMessagePlugin } from '../types' +import { BasePluginContext, UseMessagePlugin } from '../types' export interface UseMessageToolActionContext extends BasePluginContext { assistantMessage: ChatMessage @@ -31,7 +32,7 @@ export const toolPlugin = ( /** * 获取工具列表的函数。 */ - getTools: () => Promise + getTools: (context: BasePluginContext) => Promise /** * 在处理包含 tool_calls 的响应前调用。 */ @@ -100,7 +101,7 @@ export const toolPlugin = ( return createCoreToolPlugin({ ...wrappedRestOptions, - getTools: async () => (await getTools()) as any, + getTools: async (context) => getTools(runtime.createVueBaseContext(context)), beforeCallTools: beforeCallTools ? async (toolCalls, context) => { const assistantMessage = runtime.resolveReactiveMessage(context.assistantMessage as ChatMessage) From 91db99d3e2b7e2ccb200027990b8c24ed7a55476 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Fri, 15 May 2026 17:30:30 +0800 Subject: [PATCH 2/9] feat: implement skill file loading and compilation - Remove the existing weather skill markdown file. - Add browser and filesystem skill file loaders to handle skill files from different environments. - Introduce a skill compiler to manage skill definitions, instructions, and tools. - Create a skill loader to parse skill files and handle warnings. - Implement utility functions for skill path normalization and file type checking. - Add tests for skill loading and plugin functionality to ensure correct behavior. --- AGENTS.md | 104 +++++ packages/kit/package.json | 2 + .../kit/scripts/download-skill-fixtures.mjs | 128 ++++++ packages/kit/src/core.ts | 1 + packages/kit/src/index.ts | 1 + packages/kit/src/message/plugins/index.ts | 14 +- .../kit/src/message/plugins/skillPlugin.ts | 392 +----------------- packages/kit/src/message/skills/types.ts | 48 --- .../skills/vue-best-practices/SKILL.md | 154 ------- .../animation-class-based-technique.md | 254 ------------ .../animation-state-driven-technique.md | 291 ------------- .../references/component-async.md | 97 ----- .../references/component-data-flow.md | 307 -------------- .../references/component-fallthrough-attrs.md | 174 -------- .../references/component-keep-alive.md | 137 ------ .../references/component-slots.md | 216 ---------- .../references/component-suspense.md | 228 ---------- .../references/component-teleport.md | 108 ----- .../references/component-transition-group.md | 128 ------ .../references/component-transition.md | 125 ------ .../references/composables.md | 290 ------------- .../references/directives.md | 162 -------- ...rf-avoid-component-abstraction-in-lists.md | 159 ------- .../perf-v-once-v-memo-directives.md | 182 -------- .../references/perf-virtualize-large-lists.md | 187 --------- .../vue-best-practices/references/plugins.md | 166 -------- .../references/reactivity.md | 344 --------------- .../references/render-functions.md | 201 --------- .../vue-best-practices/references/sfc.md | 310 -------------- .../references/state-management.md | 135 ------ .../references/updated-hook-performance.md | 187 --------- .../test/fixtures/skills/weather/SKILL.md | 129 ------ .../skills/browserSkillLoader.ts | 6 +- packages/kit/src/skills/compiler.ts | 250 +++++++++++ .../src/{message => }/skills/fsSkillLoader.ts | 0 packages/kit/src/skills/index.ts | 25 ++ .../src/{message => }/skills/skillLoader.ts | 3 +- packages/kit/src/skills/test/.gitignore | 1 + .../test/skillLoader.test.ts | 8 +- .../test/skillPlugin.test.ts | 182 ++++---- packages/kit/src/skills/types.ts | 103 +++++ .../kit/src/{message => }/skills/utils.ts | 0 42 files changed, 743 insertions(+), 5196 deletions(-) create mode 100644 AGENTS.md create mode 100644 packages/kit/scripts/download-skill-fixtures.mjs delete mode 100644 packages/kit/src/message/skills/types.ts delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/SKILL.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-class-based-technique.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-state-driven-technique.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-async.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-data-flow.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-fallthrough-attrs.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-keep-alive.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-slots.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-suspense.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-teleport.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition-group.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/composables.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/directives.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-virtualize-large-lists.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/plugins.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/reactivity.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/render-functions.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/sfc.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/state-management.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/updated-hook-performance.md delete mode 100644 packages/kit/src/message/test/fixtures/skills/weather/SKILL.md rename packages/kit/src/{message => }/skills/browserSkillLoader.ts (93%) create mode 100644 packages/kit/src/skills/compiler.ts rename packages/kit/src/{message => }/skills/fsSkillLoader.ts (100%) create mode 100644 packages/kit/src/skills/index.ts rename packages/kit/src/{message => }/skills/skillLoader.ts (98%) create mode 100644 packages/kit/src/skills/test/.gitignore rename packages/kit/src/{message => skills}/test/skillLoader.test.ts (89%) rename packages/kit/src/{message => skills}/test/skillPlugin.test.ts (68%) create mode 100644 packages/kit/src/skills/types.ts rename packages/kit/src/{message => }/skills/utils.ts (100%) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..6d069f30e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,104 @@ +# Tiny Robot Agent Guide + +## Current Focus + +The active development track is the skill toolchain in `packages/kit`. + +The goal is to make skills a standalone capability template, not a sub-feature of `message`. A skill can be loaded from files, managed later by a manager, and compiled into prompt instructions plus tools for the message engine. + +## Current Architecture + +- `packages/kit/src/skills` + - Core skill toolchain modules. + - Owns skill loading, skill types, compiler helpers, fixtures, and skill tests. +- `packages/kit/src/message/plugins/skillPlugin.ts` + - Message runtime adapter only. + - Bridges `getSkills()` into message engine hooks. +- `packages/kit/src/message/plugins` + - Message plugins and runtime protocols. + - May re-export skill APIs for compatibility, but must not own skill core logic. + +## Package Manager + +This repository uses pnpm for dependency and script management. Prefer `pnpm` commands over `npm` commands. + +## Skill Layers + +- Loader + - Converts external sources into `SkillDefinition`. + - Examples: `SkillLoader`, `loadSkillFilesFromFs`, browser file loaders. +- Compiler + - Converts `SkillDefinition[]` into request instructions, tool schemas, runtime tools, and compiler state. + - Lives in `packages/kit/src/skills/compiler.ts`. +- Plugin Adapter + - Connects skill compiler output to message engine lifecycle. + - Lives in `packages/kit/src/message/plugins/skillPlugin.ts`. +- Manager + - Not implemented yet. + - Future responsibility: add/remove/update/list/import/select skills. + - Must not compile request messages or tools. + +## Hard Rules + +- Do not move skill core modules back under `packages/kit/src/message`. +- `skillPlugin` must not own, cache, query, mutate, or manage skill collections. +- `skillPlugin` receives the current turn's skills through `getSkills()`. +- Do not use `activeSkills` naming in the skill plugin/compiler. The plugin receives skills that are already selected by outside logic. +- Compiler may compile prompts/tools/runtime tools, but must not manage persistence, selection state, or storage. +- Loader may parse/import skill files, but must not own skill collections. +- Future manager may call loaders to import skills and may track selected skills, but must not compile request messages/tools. +- Public skill APIs should be exported from `packages/kit/src/skills/index.ts`. +- Keep `message/plugins/index.ts` compatibility exports when useful, but prefer `src/skills` as the source of truth. + +## Current Public API Shape + +```ts +skillPlugin({ + getSkills: () => [skill], +}) +``` + +Skill runtime context uses: + +```ts +context.skill +context.skills +``` + +Compiler state uses: + +```ts +state.skills +state.skillNames +state.runtimeTools +``` + +## Important Files + +- `packages/kit/src/skills/types.ts` +- `packages/kit/src/skills/compiler.ts` +- `packages/kit/src/skills/skillLoader.ts` +- `packages/kit/src/skills/fsSkillLoader.ts` +- `packages/kit/src/skills/browserSkillLoader.ts` +- `packages/kit/src/skills/index.ts` +- `packages/kit/src/skills/test/skillLoader.test.ts` +- `packages/kit/src/skills/test/skillPlugin.test.ts` +- `packages/kit/src/skills/test/fixtures` +- `packages/kit/src/message/plugins/skillPlugin.ts` + +## Validation + +Run from `packages/kit`: + +```bash +pnpm lint +pnpm test +pnpm build +``` + +## Near-Term Next Steps + +- Add focused compiler unit tests. +- Add `read_skill_file` size limits and truncation strategy. +- Design `skillManager` under `packages/kit/src/skills`. +- Keep manager boundaries separate from compiler boundaries. diff --git a/packages/kit/package.json b/packages/kit/package.json index 41ff53720..2fcede50e 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -53,6 +53,8 @@ "scripts": { "build": "tsup src/index.ts src/core.ts --format cjs,esm --dts --minify", "dev": "tsup src/index.ts src/core.ts --format cjs,esm --dts --watch", + "lint": "eslint src", + "pretest": "node scripts/download-skill-fixtures.mjs", "test": "vitest run", "test:watch": "vitest" }, diff --git a/packages/kit/scripts/download-skill-fixtures.mjs b/packages/kit/scripts/download-skill-fixtures.mjs new file mode 100644 index 000000000..f58cb140a --- /dev/null +++ b/packages/kit/scripts/download-skill-fixtures.mjs @@ -0,0 +1,128 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const cacheDirectory = join(__dirname, '../src/skills/test/.cache') + +const fixtures = [ + { + repo: 'openclaw/openclaw', + commit: '58672075219d09495de6489ad0821d276ac84f13', + sourcePath: 'skills/weather', + }, + { + repo: 'vuejs-ai/skills', + commit: 'b9d14d022da6a0a8bdcb824557f40bca6fbc1845', + sourcePath: 'skills/vue-best-practices', + }, +] + +const getFixtureTargetPath = (fixture) => { + const normalizedSourcePath = fixture.sourcePath.split('\\').join('/') + const targetName = normalizedSourcePath.split('/').filter(Boolean).at(-1) + + if (!targetName) { + throw new Error(`Invalid fixture source path: ${fixture.sourcePath}`) + } + + return join(cacheDirectory, targetName) +} + +const fetchJson = async (url) => { + const response = await fetch(url, { + headers: { + accept: 'application/vnd.github+json', + 'user-agent': '@opentiny/tiny-robot-kit skill fixture downloader', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`) + } + + return response.json() +} + +const fetchBytes = async (url) => { + const response = await fetch(url, { + headers: { + 'user-agent': '@opentiny/tiny-robot-kit skill fixture downloader', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) + } + + return new Uint8Array(await response.arrayBuffer()) +} + +const getMarkerPath = (targetPath) => join(targetPath, '.fixture-source.json') + +const hasCurrentFixture = async (fixture) => { + const targetPath = getFixtureTargetPath(fixture) + + try { + const marker = JSON.parse(await readFile(getMarkerPath(targetPath), 'utf8')) + return ( + marker.repo === fixture.repo && + marker.commit === fixture.commit && + marker.sourcePath === fixture.sourcePath + ) + } catch { + return false + } +} + +const downloadDirectory = async (fixture, sourcePath, targetPath) => { + const url = new URL(`https://api.github.com/repos/${fixture.repo}/contents/${sourcePath}`) + url.searchParams.set('ref', fixture.commit) + + const entries = await fetchJson(url) + if (!Array.isArray(entries)) { + throw new Error(`Expected directory listing for ${sourcePath}`) + } + + for (const entry of entries) { + const entryTargetPath = join(targetPath, entry.name) + + if (entry.type === 'dir') { + await downloadDirectory(fixture, entry.path, entryTargetPath) + continue + } + + if (entry.type !== 'file' || !entry.download_url) { + continue + } + + await mkdir(dirname(entryTargetPath), { recursive: true }) + await writeFile(entryTargetPath, await fetchBytes(entry.download_url)) + } +} + +for (const fixture of fixtures) { + const targetPath = getFixtureTargetPath(fixture) + + if (await hasCurrentFixture(fixture)) { + console.log(`Skill fixture already cached: ${fixture.sourcePath}@${fixture.commit}`) + continue + } + + console.log(`Downloading skill fixture: ${fixture.sourcePath}@${fixture.commit}`) + await rm(targetPath, { recursive: true, force: true }) + await mkdir(targetPath, { recursive: true }) + await downloadDirectory(fixture, fixture.sourcePath, targetPath) + await writeFile( + getMarkerPath(targetPath), + `${JSON.stringify( + { + repo: fixture.repo, + commit: fixture.commit, + sourcePath: fixture.sourcePath, + }, + null, + 2, + )}\n`, + ) +} diff --git a/packages/kit/src/core.ts b/packages/kit/src/core.ts index bdd87948e..6d900f71c 100644 --- a/packages/kit/src/core.ts +++ b/packages/kit/src/core.ts @@ -3,3 +3,4 @@ export * from './message/core' export * from './message/plugins' export * from './message/types' export { combineDeltaData, normalizeToAsyncGenerator } from './message/utils' +export * from './skills' diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index 1456bf582..7cb3d2fb8 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -1,6 +1,7 @@ export { AIClient } from './client' export { BaseModelProvider } from './providers/base' export { OpenAIProvider } from './providers/openai' +export * from './skills' export * from './storage' export * from './types' export { extractTextFromResponse, formatMessages, handleSSEStream, sseStreamToGenerator } from './utils' diff --git a/packages/kit/src/message/plugins/index.ts b/packages/kit/src/message/plugins/index.ts index 77b42113e..dd41311f5 100644 --- a/packages/kit/src/message/plugins/index.ts +++ b/packages/kit/src/message/plugins/index.ts @@ -4,11 +4,21 @@ export type { SkillFile, SkillFileKind, SkillFileResource, + SkillDefinition, + SkillRuntimeContext, TextSkillFile, -} from '../skills/types' +} from '../../skills/types' +export { + compileSkillInstructions, + compileSkillTools, + createSkillCompilerState, + createSkillFileRuntimeTools, + uniqueSkills, +} from '../../skills/compiler' +export type { SkillCompilerState } from '../../skills/compiler' export { lengthPlugin } from './lengthPlugin' export { skillPlugin } from './skillPlugin' -export type { SkillDefinition } from './skillPlugin' +export type { SkillPluginState } from './skillPlugin' export { thinkingPlugin } from './thinkingPlugin' export { toolPlugin } from './toolPlugin' export type { RuntimeTool, ToolCallContext, ToolProvider, ToolProviderItem } from './toolPlugin' diff --git a/packages/kit/src/message/plugins/skillPlugin.ts b/packages/kit/src/message/plugins/skillPlugin.ts index 0d5b964ea..140f989a4 100644 --- a/packages/kit/src/message/plugins/skillPlugin.ts +++ b/packages/kit/src/message/plugins/skillPlugin.ts @@ -1,8 +1,9 @@ -import type { ChatCompletionFunctionTool } from 'openai/resources' import type { MaybePromise } from '../../types' import type { BasePluginContext, BeforeRequestContext, MessageEnginePlugin } from '../types' -import type { SkillFileResource } from '../skills/types' -import type { RuntimeTool, ToolProvider } from './toolPlugin' +import { compileSkillInstructions, compileSkillTools, createSkillCompilerState } from '../../skills/compiler' +import type { SkillCompilerState } from '../../skills/compiler' +import type { SkillDefinition } from '../../skills/types' +import type { ToolProvider } from './toolPlugin' /** * TODO(skillPlugin): @@ -12,144 +13,38 @@ import type { RuntimeTool, ToolProvider } from './toolPlugin' * 2. 实现 skill files 消费策略:支持文件选择、去重、上下文格式化与长度控制。 * Implement skill files consumption strategy: file selection, deduplication, * context formatting, and length control. - * 3. 增加 Vue 层状态暴露封装:将当前 active skills 同步给 UI 展示或调试面板。 - * Expose Vue-side state for active skills so UI/debug panels can display them. - * 4. 补充测试:覆盖 getActiveSkills、customContext 写入、compileSkills 调用、 - * 重名 skill 去重、未知 skill 忽略以及和其他插件的 hook 顺序。 - * Add tests for getActiveSkills, customContext state, compileSkills, - * duplicate skill deduplication, unknown skill ignoring, and plugin hook ordering. + * 3. 增加 Vue 层状态暴露封装:将当前 skills 同步给 UI 展示或调试面板。 + * Expose Vue-side state for current skills so UI/debug panels can display them. + * 4. 补充测试:覆盖 getSkills、customContext 写入、compileSkills 调用、 + * 重名 skill 去重以及和其他插件的 hook 顺序。 + * Add tests for getSkills, customContext state, compileSkills, + * duplicate skill deduplication, and plugin hook ordering. */ /** - * Skill 解析上下文。 - * - * 用于由业务侧通过统一入口决定当前 turn 应激活哪些 skills。 - * 具体激活来源可以是 UI 勾选、规则匹配、模型选择、后端策略或 skills 管理工具。 - */ -export interface SkillResolveContext extends BasePluginContext { - /** - * 当前插件持有的全部 skill 定义。 - */ - skills: SkillDefinition[] -} - -/** - * 单个 Skill 的运行时上下文。 - * - * 用于动态生成 instructions、tools,或在回调中读取当前已激活的 skill 列表。 - */ -export interface SkillRuntimeContext extends BasePluginContext { - /** - * 当前正在处理的 skill。 - */ - skill: SkillDefinition - /** - * 当前 turn 已激活的全部 skills。 - */ - activeSkills: SkillDefinition[] -} - -/** - * Skill 定义。 - * - * Skill 是一组提示词、工具和文件上下文的能力包。它最终通常会被编译为: - * - system/developer prompt - * - requestBody.tools - * - 可按需读取的文件上下文 - * - * 当前插件先提供类型和生命周期框架,具体编译策略后续再实现。 - */ -export interface SkillDefinition { - /** - * Skill 唯一名称。用于激活、去重、调试和持久化。 - */ - name: string - /** - * Skill 能力描述。可用于自动匹配,也可作为模型选择 skill 时的说明。 - */ - description: string - /** - * 注入给模型的 skill 指令。 - * - * 后续可在 onBeforeRequest 中编译为 system/developer message。 - */ - instructions?: string | ((context: SkillRuntimeContext) => MaybePromise) - /** - * Skill 暴露的工具列表。 - * - * 后续可在 onBeforeRequest 中合并到 requestBody.tools,并复用 toolPlugin 执行 tool_calls。 - */ - tools?: ChatCompletionFunctionTool[] | ((context: SkillRuntimeContext) => MaybePromise) - /** - * Skill 目录下除入口文件和工具配置外的文件数据。 - * - * files 表示 skill 自带的静态文件全集;本轮选择哪些文件应由编译策略决定。 - */ - files?: SkillFileResource[] - /** - * 业务侧自定义元数据。 - */ - metadata?: Record -} - -/** - * 本轮 skill 解析结果。 + * 本轮 skill 转换状态。 * * 该对象会写入 customContext.__tiny_robot_skill,供后续插件或业务回调读取。 */ -export interface SkillPluginState { - /** - * 当前 turn 激活的 skill 定义。 - */ - activeSkills: SkillDefinition[] - /** - * 当前 turn 激活的 skill 名称。便于展示、日志和序列化。 - */ - activeSkillNames: string[] - /** - * 当前 turn 的运行时工具。 - * - * 由 toolPlugin.getTools(context) 读取并统一注入、执行。 - */ - runtimeTools?: RuntimeTool[] -} - -/** - * Skill 引用。 - * - * 可以传入 skill name,也可以直接传入 SkillDefinition。 - * 传入 name 时会从 options.skills 中查找;查不到的名称会被忽略。 - */ -type SkillRef = string | SkillDefinition +export type SkillPluginState = SkillCompilerState /** * skillPlugin 配置项。 */ export type SkillPluginOptions = MessageEnginePlugin & { /** - * 可用 skill 列表。 - */ - skills: SkillDefinition[] - /** - * 获取当前 turn 激活的 skills。 + * 获取当前 turn 要转换的 skills。 * - * 这是 skillPlugin 唯一的激活入口。UI 选择、规则匹配、模型选择、后端策略 - * 或独立的 skills 管理工具都应在外部收敛为这个结果,插件不关心激活来源。 + * skillPlugin 不持有、不查询、不缓存可用 skill 列表。UI 选择、规则匹配、模型选择、后端策略 + * 或独立的 skills 管理工具都应在外部收敛为本轮要转换的 SkillDefinition 列表。 */ - getActiveSkills?: (context: SkillResolveContext) => MaybePromise + getSkills?: (context: BasePluginContext) => MaybePromise /** - * Skill 解析完成后触发。 + * Skills 获取并去重完成后触发。 * - * 可用于记录日志、同步 UI 状态或调试激活结果。 + * 可用于记录日志、同步 UI 状态或调试本轮转换结果。 */ onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise - /** - * 单个 skill 被激活时触发。 - */ - onSkillActivated?: ( - skill: SkillDefinition, - context: BasePluginContext & { activeSkills: SkillDefinition[] }, - ) => MaybePromise /** * 请求前的 skill 编译钩子。 * @@ -159,247 +54,14 @@ export type SkillPluginOptions = MessageEnginePlugin & { compileSkills?: (state: SkillPluginState, context: BeforeRequestContext) => MaybePromise } -const uniqueSkills = (skills: SkillDefinition[]) => { - const result: SkillDefinition[] = [] - const names = new Set() - - for (const skill of skills) { - if (names.has(skill.name)) { - continue - } - - names.add(skill.name) - result.push(skill) - } - - return result -} - const skillPluginContextKey = '__tiny_robot_skill' -const skillFileToolNames = { - listSkillFiles: 'list_skill_files', - readSkillFile: 'read_skill_file', -} as const - -const skillFileTools: Array = [ - { - type: 'function', - function: { - name: skillFileToolNames.listSkillFiles, - description: 'List files available from the active skills.', - parameters: { - type: 'object', - properties: { - skillName: { - type: 'string', - description: 'Optional active skill name. When omitted, files from all active skills are listed.', - }, - }, - additionalProperties: false, - }, - }, - }, - { - type: 'function', - function: { - name: skillFileToolNames.readSkillFile, - description: 'Read a file from an active skill by skill name and relative path.', - parameters: { - type: 'object', - properties: { - skillName: { - type: 'string', - description: 'Active skill name that owns the file.', - }, - path: { - type: 'string', - description: 'File path relative to the skill root.', - }, - }, - required: ['skillName', 'path'], - additionalProperties: false, - }, - }, - }, -] - -const hasActiveSkillFiles = (skills: SkillDefinition[]) => skills.some((skill) => Boolean(skill.files?.length)) - -const getSkillFileSummary = (skillName: string, file: SkillFileResource) => ({ - skillName, - id: file.id, - path: file.path, - kind: file.kind, - mimeType: file.mimeType, - size: file.size, - lastModified: file.lastModified, -}) - -const parseSkillToolArguments = (toolCall: Parameters[0]): Record => { - const rawArguments = toolCall.function.arguments - - if (!rawArguments) { - return {} - } - - try { - const parsed = JSON.parse(rawArguments) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} - } catch { - return {} - } -} - -const createSkillFileRuntimeTools = (activeSkills: SkillDefinition[]): RuntimeTool[] => { - if (!hasActiveSkillFiles(activeSkills)) { - return [] - } - - const findSkill = (skillName?: unknown) => { - if (typeof skillName !== 'string' || !skillName) { - return undefined - } - - return activeSkills.find((skill) => skill.name === skillName) - } - - return [ - { - tool: skillFileTools[0], - handler: (toolCall) => { - const toolArguments = parseSkillToolArguments(toolCall) - const skill = findSkill(toolArguments.skillName) - const skills = skill ? [skill] : activeSkills - - return { - files: skills.flatMap((activeSkill) => - (activeSkill.files ?? []).map((file) => getSkillFileSummary(activeSkill.name, file)), - ), - } - }, - }, - { - tool: skillFileTools[1], - handler: (toolCall) => { - const toolArguments = parseSkillToolArguments(toolCall) - const skill = findSkill(toolArguments.skillName) - const path = typeof toolArguments.path === 'string' ? toolArguments.path : undefined - - if (!skill) { - return { error: 'skill_not_found' } - } - - if (!path) { - return { error: 'file_path_required', skillName: skill.name } - } - - const file = skill.files?.find((skillFile) => skillFile.path === path) - if (!file) { - return { error: 'file_not_found', skillName: skill.name, path } - } - - if (file.kind === 'binary') { - return { - error: 'binary_file_not_readable', - file: getSkillFileSummary(skill.name, file), - } - } - - return { - file: getSkillFileSummary(skill.name, file), - content: file.content, - } - }, - }, - ] -} - -const resolveSkillInstructions = async (skill: SkillDefinition, context: SkillRuntimeContext) => { - if (!skill.instructions) { - return '' - } - - const instructions = typeof skill.instructions === 'function' ? await skill.instructions(context) : skill.instructions - return instructions.trim() -} - -const resolveSkillTools = async (skill: SkillDefinition, context: SkillRuntimeContext) => { - if (!skill.tools) { - return [] - } - - return typeof skill.tools === 'function' ? await skill.tools(context) : skill.tools -} - -const compileActiveSkills = async (state: SkillPluginState, context: BeforeRequestContext) => { - const instructions: string[] = [] - - for (const skill of state.activeSkills) { - const runtimeContext: SkillRuntimeContext = { - ...context, - skill, - activeSkills: state.activeSkills, - } - - const instruction = await resolveSkillInstructions(skill, runtimeContext) - if (instruction) { - instructions.push(`## ${skill.name}\n\n${instruction}`) - } - } - - if (instructions.length > 0) { - context.requestBody.messages = [ - { - role: 'system', - content: ['Apply these skill instructions when generating the response.', ...instructions].join('\n\n'), - }, - ...context.requestBody.messages, - ] - } -} - export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin => { - const { skills, getActiveSkills, onSkillsResolved, onSkillActivated, compileSkills, ...restOptions } = options - - const skillMap = new Map(skills.map((skill) => [skill.name, skill])) - - const resolveSkillRefs = (skillRefs: SkillRef[] = []) => { - const activeSkills = skillRefs - .map((skill) => { - if (typeof skill === 'string') { - return skillMap.get(skill) - } - - return skill - }) - .filter((skill): skill is SkillDefinition => Boolean(skill)) - - return uniqueSkills(activeSkills) - } - - const resolveActiveSkills = async (context: BasePluginContext) => { - const resolveContext: SkillResolveContext = { ...context, skills } - return resolveSkillRefs(await getActiveSkills?.(resolveContext)) - } + const { getSkills, onSkillsResolved, compileSkills, ...restOptions } = options const provideSkillTools = async (context: BasePluginContext) => { const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined - if (!state) { - return [] - } - - const skillTools: ChatCompletionFunctionTool[] = [] - for (const skill of state.activeSkills) { - const runtimeContext: SkillRuntimeContext = { - ...context, - skill, - activeSkills: state.activeSkills, - } - skillTools.push(...(await resolveSkillTools(skill, runtimeContext))) - } - - return [...(state.runtimeTools ?? []), ...skillTools] + return state ? compileSkillTools(state, context) : [] } return { @@ -407,20 +69,10 @@ export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin => ...restOptions, provideTools: provideSkillTools, onTurnStart: async (context) => { - const activeSkills = await resolveActiveSkills(context) - const runtimeTools = createSkillFileRuntimeTools(activeSkills) - const state: SkillPluginState = { - activeSkills, - activeSkillNames: activeSkills.map((skill) => skill.name), - runtimeTools: runtimeTools.length ? runtimeTools : undefined, - } + const state = createSkillCompilerState((await getSkills?.(context)) ?? []) context.setCustomContext({ [skillPluginContextKey]: state }) - for (const skill of activeSkills) { - await onSkillActivated?.(skill, { ...context, activeSkills }) - } - await onSkillsResolved?.(state, context) return restOptions.onTurnStart?.(context) }, @@ -428,7 +80,7 @@ export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin => const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined if (state) { - await compileActiveSkills(state, context) + await compileSkillInstructions(state, context) await compileSkills?.(state, context) } diff --git a/packages/kit/src/message/skills/types.ts b/packages/kit/src/message/skills/types.ts deleted file mode 100644 index b8f5596f4..000000000 --- a/packages/kit/src/message/skills/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type SkillFileKind = 'text' | 'binary' - -/** - * Skill 文件的公共数据模型。 - * - * 同时支持 browser (File API / showDirectoryPicker) 和 Node.js (fs) 两种环境。 - */ -export interface BaseSkillFile { - /** - * 基于 skill 根目录的相对路径。必须使用 / 分隔,不能以 / 开头,不能包含 ..。 - */ - path: string - /** - * MIME 类型。 - */ - mimeType?: string - /** - * 文件大小(字节)。 - */ - size?: number - /** - * 最后修改时间(时间戳)。 - */ - lastModified?: number - /** - * 文件元数据。可放来源、优先级、版本号等业务字段。 - */ - metadata?: Record -} - -export interface TextSkillFile extends BaseSkillFile { - kind: 'text' - content: string -} - -export interface BinarySkillFile extends BaseSkillFile { - kind: 'binary' - content: ArrayBuffer | Uint8Array -} - -export type SkillFile = TextSkillFile | BinarySkillFile - -export type SkillFileResource = SkillFile & { - /** - * 文件唯一标识。在同一个 skill 内应保持唯一,默认使用 path。 - */ - id: string -} diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/SKILL.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/SKILL.md deleted file mode 100644 index feacd704f..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/SKILL.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -name: vue-best-practices -description: MUST be used for Vue.js tasks. Strongly recommends Composition API with ` - - -``` - -## Common Animation Patterns - -### Pulse on Success - -```vue - - - - - -``` - -### Highlight on Change - -```vue - - - - - -``` - -### Bounce Attention - -```vue - - - - - -``` - -## Using animationend Event - -Instead of `setTimeout`, use the `animationend` event for cleaner code: - -```vue - - - -``` - -## Composable for Reusable Animations - -```javascript -// composables/useAnimation.js -import { ref } from 'vue' - -export function useAnimation(duration = 500) { - const isAnimating = ref(false) - - function trigger() { - isAnimating.value = true - setTimeout(() => { - isAnimating.value = false - }, duration) - } - - return { - isAnimating, - trigger - } -} -``` - -```vue - - - -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-state-driven-technique.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-state-driven-technique.md deleted file mode 100644 index 26b012018..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/animation-state-driven-technique.md +++ /dev/null @@ -1,291 +0,0 @@ ---- -title: State-driven Animations with CSS Transitions and Style Bindings -impact: LOW -impactDescription: Combining Vue's reactive style bindings with CSS transitions creates smooth, interactive animations -type: best-practice -tags: [vue3, animation, css, transition, style-binding, state, interactive] ---- - -# State-driven Animations with CSS Transitions and Style Bindings - -**Impact: LOW** - For responsive, interactive animations that react to user input or state changes, combine Vue's dynamic style bindings with CSS transitions. This creates smooth animations that interpolate values in real-time based on state. - -## Task List - -- Use `:style` binding for dynamic properties that change frequently -- Add CSS `transition` property to smoothly animate between values -- Consider using `transform` and `opacity` for GPU-accelerated animations -- For complex value interpolation, use watchers with animation libraries - -## Basic Pattern - -```vue - - - - - -``` - -## Common Use Cases - -### Following Mouse Position - -```vue - - - - - -``` - -### Progress Animation - -```vue - - - - - -``` - -### Scroll-based Animation - -```vue - - - - - -``` - -### Color Theme Transition - -```vue - - - - - -``` - -## Advanced: Numerical Tweening with Watchers - -For smooth number animations (counters, stats), use watchers with animation libraries: - -```vue - - - -``` - -## Performance Considerations - -```vue - -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-async.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-async.md deleted file mode 100644 index b39310d2e..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-async.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Async Component Best Practices -impact: MEDIUM -impactDescription: Poor async component strategy can delay interactivity in SSR apps and create loading UI flicker -type: best-practice -tags: [vue3, async-components, ssr, hydration, performance, ux] ---- - -# Async Component Best Practices - -**Impact: MEDIUM** - Async components should reduce JavaScript cost without degrading perceived performance. Focus on hydration timing in SSR and stable loading UX. - -## Task List - -- Use lazy hydration strategies for non-critical SSR component trees -- Import only the hydration helpers you actually use -- Keep `loadingComponent` delay near the default `200ms` unless real UX data suggests otherwise -- Configure `delay` and `timeout` together for predictable loading behavior - -## Use Lazy Hydration Strategies in SSR - -In Vue 3.5+, async components can delay hydration until idle time, visibility, media query match, or user interaction. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Prevent Loading Spinner Flicker - -Avoid showing loading UI immediately for components that usually resolve quickly. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Delay Guidelines - -| Scenario | Recommended Delay | -|----------|-------------------| -| Small component, fast network | `200ms` | -| Known heavy component | `100ms` | -| Background or non-critical UI | `300-500ms` | diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-data-flow.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-data-flow.md deleted file mode 100644 index e1add1e8a..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-data-flow.md +++ /dev/null @@ -1,307 +0,0 @@ ---- -title: Component Data Flow Best Practices -impact: HIGH -impactDescription: Clear data flow between components prevents state bugs, stale UI, and brittle coupling -type: best-practice -tags: [vue3, props, emits, v-model, provide-inject, data-flow, typescript] ---- - -# Component Data Flow Best Practices - -**Impact: HIGH** - Vue components stay reliable when data flow is explicit: props go down, events go up, `v-model` handles two-way bindings, and provide/inject supports cross-tree dependencies. Blurring these boundaries leads to stale state, hidden coupling, and hard-to-debug UI. - -The main principle of data flow in Vue.js is **Props Down / Events Up**. This is the most maintainable default, and one-way flow scales well. - -## Task List - -- Treat props as read-only inputs -- Use props/emit for component communication; reserve refs for imperative actions -- When refs are required for imperative APIs, type them with template refs -- Emit events instead of mutating parent state directly -- Use `defineModel` for v-model in modern Vue (3.4+) -- Handle v-model modifiers deliberately in child components -- Use symbols for provide/inject keys to avoid props drilling (over ~3 layers) -- Keep mutations in the provider or expose explicit actions -- In TypeScript projects, prefer type-based `defineProps`, `defineEmits`, and `InjectionKey` - -## Props: One-Way Data Down - -Props are inputs. Do not mutate them in the child. - -**BAD:** -```vue - -``` - -**GOOD:** - -If state needs to change, emit an event, use `v-model` or create a local copy. - -## Prefer props/emit over component refs - -**BAD:** -```vue - - - -``` - -**GOOD:** -```vue - - - -``` - -## Type component refs when imperative access is required - -Prefer props/emits by default. When a parent must call an exposed child method, type the ref explicitly and expose only the intended API from the child with `defineExpose`. - -**BAD:** -```vue - - - -``` - -**GOOD:** -```vue - - -``` - -```vue - - - - -``` - -## Emits: Explicit Events Up - -Component events do not bubble. If a parent needs to know about an event, re-emit it explicitly. - -**BAD:** -```vue - - -``` - -**GOOD:** -```vue - - - - -``` - -**Event naming:** use kebab-case in templates and camelCase in script: -```vue - - - -``` - -## `v-model`: Predictable Two-Way Bindings - -Use `defineModel` by default for component bindings and emit updates on input. Only use the `modelValue` + `update:modelValue` pattern if you are on Vue < 3.4. - -**BAD:** -```vue - - - -``` - -**GOOD (Vue 3.4+):** -```vue - - - -``` - -**GOOD (Vue < 3.4):** -```vue - - - -``` - -If you need the updated value immediately after a change, use the input event value or `nextTick` in the parent. - -## Provide/Inject: Shared Context Without Prop Drilling - -Use provide/inject for cross-tree state, but keep mutations centralized in the provider and expose explicit actions. - -**BAD:** -```vue -// Provider.vue -provide('theme', reactive({ dark: false })) - -// Consumer.vue -const theme = inject('theme') -// Mutating shared state from any depth becomes hard to track -theme.dark = true -``` - -**GOOD:** -```vue -// Provider.vue -const theme = reactive({ dark: false }) -const toggleTheme = () => { theme.dark = !theme.dark } - -provide(themeKey, readonly(theme)) -provide(themeActionsKey, { toggleTheme }) - -// Consumer.vue -const theme = inject(themeKey) -const { toggleTheme } = inject(themeActionsKey) -``` - -Use symbols for keys to avoid collisions in large apps: -```ts -export const themeKey = Symbol('theme') -export const themeActionsKey = Symbol('theme-actions') -``` - -## Use TypeScript Contracts for Public Component APIs - -In TypeScript projects, type component boundaries directly with `defineProps`, `defineEmits`, and `InjectionKey` so invalid payloads and mismatched injections fail at compile time. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-fallthrough-attrs.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-fallthrough-attrs.md deleted file mode 100644 index 5362fa4af..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-fallthrough-attrs.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -title: Component Fallthrough Attributes Best Practices -impact: MEDIUM -impactDescription: Incorrect $attrs access and reactivity assumptions can cause undefined values and watchers that never run -type: best-practice -tags: [vue3, attrs, fallthrough-attributes, composition-api, reactivity] ---- - -# Component Fallthrough Attributes Best Practices - -**Impact: MEDIUM** - Fallthrough attributes are straightforward once you follow Vue's conventions: hyphenated names use bracket notation, listener keys are camelCase `onX`, and `useAttrs()` is current-but-not-reactive. - -## Task List - -- Access hyphenated attribute names with bracket notation (for example `attrs['data-testid']`) -- Access event listeners with camelCase `onX` keys (for example `attrs.onClick`) -- Do not `watch()` values returned from `useAttrs()`; those watchers do not trigger on attr changes -- Use `onUpdated()` for attr-driven side effects -- Promote frequently observed attrs to props when reactive observation is required - -## Access Attribute and Listener Keys Correctly - -Hyphenated attribute names preserve their original casing in JavaScript, so dot notation does not work for keys that include `-`. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -### Naming Reference - -| Parent Usage | Access in `attrs` | -|--------------|-------------------| -| `class="foo"` | `attrs.class` | -| `data-id="123"` | `attrs['data-id']` | -| `aria-label="..."` | `attrs['aria-label']` | -| `foo-bar="baz"` | `attrs['foo-bar']` | -| `@click="fn"` | `attrs.onClick` | -| `@custom-event="fn"` | `attrs.onCustomEvent` | -| `@update:modelValue="fn"` | `attrs['onUpdate:modelValue']` | - -## `useAttrs()` Is Not Reactive - -`useAttrs()` always reflects the latest values, but it is intentionally not reactive for watcher tracking. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Common Patterns - -### Check for optional attrs safely - -```vue - -``` - -### Forward listeners after internal logic - -```vue - - - -``` - -## TypeScript Notes - -`useAttrs()` is typed as `Record`, so cast individual keys when needed. - -```vue - -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-keep-alive.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-keep-alive.md deleted file mode 100644 index f887691fe..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-keep-alive.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: KeepAlive Component Best Practices -impact: HIGH -impactDescription: KeepAlive caches component instances; misuse causes stale data, memory growth, or unexpected lifecycle behavior -type: best-practice -tags: [vue3, keepalive, cache, performance, router, dynamic-components] ---- - -# KeepAlive Component Best Practices - -**Impact: HIGH** - `` caches component instances instead of destroying them. Use it to preserve state across switches, but manage cache size and freshness explicitly to avoid memory growth or stale UI. - -## Task List - -- Use KeepAlive only where state preservation improves UX -- Set a reasonable `max` to cap cache size -- Declare component names for include/exclude matching -- Use `onActivated`/`onDeactivated` for cache-aware logic -- Decide how and when cached views refresh their data -- Avoid caching memory-heavy or security-sensitive views - -## When to Use KeepAlive - -Use KeepAlive when switching between views where state should persist (tabs, multi-step forms, dashboards). Avoid it when each visit should start fresh. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## When NOT to Use KeepAlive - -- Search or filter pages where users expect fresh results -- Memory-heavy components (maps, large tables, media players) -- Sensitive flows where data must be cleared on exit -- Components with heavy background activity you cannot pause - -## Limit and Control the Cache - -Always cap cache size with `max` and restrict caching to specific components when possible. - -```vue - -``` - -## Ensure Component Names Match include/exclude - -`include` and `exclude` match the component `name` option. Explicitly set names for reliable caching. - -```vue - - -``` - -```vue - -``` - -## Cache Invalidation Strategies - -Vue 3 has no direct API to remove a specific cached instance. Use keys or dynamic include/exclude to force refreshes. - -```vue - - - -``` - -## Lifecycle Hooks for Cached Components - -Cached components are not destroyed on switch. Use activation hooks for refresh and cleanup. - -```vue - -``` - -## Router Caching and Freshness - -Decide whether navigation should show cached state or a fresh view. A common pattern is to key by route when params change. - -```vue - -``` - -If you want cache reuse but fresh data, refresh in `onActivated` and compare query/params before fetching. diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-slots.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-slots.md deleted file mode 100644 index f77a91c5c..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-slots.md +++ /dev/null @@ -1,216 +0,0 @@ ---- -title: Component Slots Best Practices -impact: MEDIUM -impactDescription: Poor slot API design causes empty DOM wrappers, weak TypeScript safety, brittle defaults, and unnecessary component overhead -type: best-practice -tags: [vue3, slots, components, typescript, composables] ---- - -# Component Slots Best Practices - -**Impact: MEDIUM** - Slots are a core component API surface in Vue. Structure them intentionally so templates stay predictable, typed, and performant. - -## Task List - -- Use shorthand syntax for named slots (`#` instead of `v-slot:`) -- Render optional slot wrapper elements only when slot content exists (`$slots` checks) -- Type scoped slot contracts with `defineSlots` in TypeScript components -- Provide fallback content for optional slots -- Prefer composables over renderless components for pure logic reuse - -## Shorthand syntax for named slots - -**BAD:** -```vue - - - -``` - -**GOOD:** -```vue - - - -``` - -## Conditionally Render Optional Slot Wrappers - -Use `$slots` checks when wrapper elements add spacing, borders, or layout constraints. - -**BAD:** -```vue - - -``` - -**GOOD:** -```vue - - -``` - -## Type Scoped Slot Props with defineSlots - -In ` - - -``` - -**GOOD:** -```vue - - - - -``` - -## Provide Slot Fallback Content - -Fallback content makes components resilient when parents omit optional slots. - -**BAD:** -```vue - - -``` - -**GOOD:** -```vue - - -``` - -## Prefer Composables for Pure Logic Reuse - -Renderless components are still useful for slot-driven composition, but composables are usually cleaner for logic-only reuse. - -**BAD:** -```vue - - - - -``` - -**GOOD:** -```ts -// composables/useMouse.ts -import { ref, onMounted, onUnmounted } from 'vue' - -export function useMouse() { - const x = ref(0) - const y = ref(0) - - function onMove(event: MouseEvent) { - x.value = event.pageX - y.value = event.pageY - } - - onMounted(() => window.addEventListener('mousemove', onMove)) - onUnmounted(() => window.removeEventListener('mousemove', onMove)) - - return { x, y } -} -``` - -```vue - - - - -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-suspense.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-suspense.md deleted file mode 100644 index 4d9ecab9d..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-suspense.md +++ /dev/null @@ -1,228 +0,0 @@ ---- -title: Suspense Component Best Practices -impact: MEDIUM -impactDescription: Suspense coordinates async dependencies with fallback UI; misconfiguration leads to missing loading states or confusing UX -type: best-practice -tags: [vue3, suspense, async-components, async-setup, loading, fallback, router, transition, keepalive] ---- - -# Suspense Component Best Practices - -**Impact: MEDIUM** - `` coordinates async dependencies (async components or async setup) and renders a fallback while they resolve. Misconfiguration leads to missing loading states, empty renders, or subtle UX bugs. - -## Task List - -- Wrap default and fallback slot content in a single root node -- Use `timeout` when you need the fallback to appear on reverts -- Force root replacement with `:key` when you need Suspense to re-trigger -- Add `suspensible` to nested Suspense boundaries (Vue 3.3+) -- Use `@pending`, `@resolve`, and `@fallback` for programmatic loading state -- Nest `RouterView` -> `Transition` -> `KeepAlive` -> `Suspense` in that order -- Keep Suspense usage centralized and documented in production - -## Single Root in Default and Fallback Slots - -Suspense tracks a single immediate child in both slots. Wrap multiple elements in a single element or component. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Fallback Timing on Reverts (`timeout`) - -When Suspense is already resolved and new async work starts, the previous content remains visible until the timeout elapses. Use `timeout="0"` for immediate fallback or a short delay to avoid flicker. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Pending State Only Re-triggers on Root Replacement - -Once resolved, Suspense only re-enters pending when the root node of the default slot changes. If async work happens deeper in the tree, no fallback appears. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Use `suspensible` for Nested Suspense (Vue 3.3+) - -Nested Suspense boundaries need `suspensible` on the inner boundary so the parent can coordinate loading state. Without it, inner async content may render empty nodes until resolved. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Track Loading with Suspense Events - -Use `@pending`, `@resolve`, and `@fallback` for analytics, global loading indicators, or coordinating UI outside the Suspense boundary. - -```vue - - - -``` - -## Recommended Nesting with RouterView, Transition, KeepAlive - -When combining these components, the nesting order should be `RouterView` -> `Transition` -> `KeepAlive` -> `Suspense` so each wrapper works correctly. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Treat Suspense Cautiously in Production - -In production code, keep Suspense boundaries minimal, document where they are used, and have a fallback loading strategy if you ever need to replace or refactor them. diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-teleport.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-teleport.md deleted file mode 100644 index db48db2d7..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-teleport.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: Teleport Component Best Practices -impact: MEDIUM -impactDescription: Teleport renders content outside the component's DOM position, which is essential for overlays but affects styling and layout -type: best-practice -tags: [vue3, teleport, modal, overlay, positioning, responsive] ---- - -# Teleport Component Best Practices - -**Impact: MEDIUM** - `` renders part of a component's template in a different place in the DOM while preserving the Vue component hierarchy. Use it for overlays (modals, toasts, tooltips) or any UI that must escape stacking contexts, overflow, or fixed positioning constraints. - -## Task List - -- Teleport overlays to `body` or a dedicated container outside the app root -- Keep a shared target for similar UI (`#modals`, `#notifications`) and control layering with order or z-index -- Use `:disabled` for responsive layouts that should render inline on small screens -- Remember props, emits, and provide/inject still work through teleport -- Avoid relying on parent stacking contexts or transforms for teleported UI - -## Teleport Overlays Out of Transformed Containers - -When an ancestor has `transform`, `filter`, or `perspective`, fixed-position overlays can behave like they are locally positioned. Teleport escapes that context. - -**BAD:** -```vue - - - -``` - -**GOOD:** -```vue - -``` - -## Responsive Layouts with `disabled` - -Use `:disabled` to render inline on mobile and teleport on larger screens: - -```vue - - - -``` - -## Logical Hierarchy Is Preserved - -Teleport changes DOM position, not the Vue component tree. Props, emits, slots, and provide/inject still work: - -```vue - -``` - -## Multiple Teleports to the Same Target - -Teleports to the same target append in declaration order: - -```vue - -``` - -Use a shared container to keep stacking predictable, and apply z-index only when you need explicit layering. diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition-group.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition-group.md deleted file mode 100644 index d0339ff4d..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition-group.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: TransitionGroup Component Best Practices -impact: MEDIUM -impactDescription: TransitionGroup animates list items; missing keys or misuse leads to broken list transitions -type: best-practice -tags: [vue3, transition-group, animation, lists, keys] ---- - -# TransitionGroup Component Best Practices - -**Impact: MEDIUM** - `` animates lists of items entering, leaving, and moving. Use it for `v-for` lists or dynamic collections where individual items change over time. - -## Task List - -- Use `` only for lists and repeated items -- Provide unique, stable keys for every direct child -- Use `tag` when you need semantic or layout wrappers -- Avoid the `mode` prop (not supported) -- Use JavaScript hooks for staggered effects - -## Use TransitionGroup for Lists - -`` is designed for list items. Use `tag` to control the wrapper element when needed. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Always Provide Stable Keys - -Keys are required. Without stable keys, Vue cannot track item positions and animations break. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Do Not Use `mode` on TransitionGroup - -`mode` is only for `` because it swaps a single element. Use `` if you need in/out sequencing. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Stagger List Animations with Data Attributes - -For cascading list animations, pass the index to JavaScript hooks and compute delay per item. - -```vue - - - -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition.md deleted file mode 100644 index e6abed783..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/component-transition.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -title: Transition Component Best Practices -impact: MEDIUM -impactDescription: Transition animates a single element or component; incorrect structure or keys prevent animations -type: best-practice -tags: [vue3, transition, animation, performance, keys] ---- - -# Transition Component Best Practices - -**Impact: MEDIUM** - `` animates entering/leaving of a single element or component. It is ideal for toggling UI states, swapping views, or animating one component at a time. - -## Task List - -- Wrap a single element or component inside `` -- Provide a `key` when switching between same element types -- Use `mode="out-in"` when you need sequential swaps -- Prefer `transform` and `opacity` for smooth animations - -## Use Transition for a Single Root Element - -`` only supports one direct child. Wrap multiple nodes in a single element or component. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Force Transitions Between Same Element Types - -Vue reuses the same DOM element when the tag type does not change. Add `key` so Vue treats it as a new element and triggers enter/leave. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Use `mode` to Avoid Overlap During Swaps - -When swapping components or views, use `mode="out-in"` to prevent both from being visible at the same time. - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Animate `transform` and `opacity` for Performance - -Avoid layout-triggering properties such as `height`, `margin`, or `top`. Use `transform` and `opacity` for smooth, GPU-friendly transitions. - -**BAD:** -```css -.slide-enter-active, -.slide-leave-active { - transition: height 0.3s ease; -} - -.slide-enter-from, -.slide-leave-to { - height: 0; -} -``` - -**GOOD:** -```css -.slide-enter-active, -.slide-leave-active { - transition: transform 0.3s ease, opacity 0.3s ease; -} - -.slide-enter-from { - transform: translateX(-12px); - opacity: 0; -} - -.slide-leave-to { - transform: translateX(12px); - opacity: 0; -} -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/composables.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/composables.md deleted file mode 100644 index cb18a6f8a..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/composables.md +++ /dev/null @@ -1,290 +0,0 @@ ---- -title: Composable Organization Patterns -impact: MEDIUM -impactDescription: Well-structured composables improve maintainability, reusability, and update performance -type: best-practice -tags: [vue3, composables, composition-api, code-organization, api-design, readonly, utilities] ---- - -# Composable Organization Patterns - -**Impact: MEDIUM** - Treat composables as reusable, stateful building blocks and keep their code organized by feature concern. This keeps large components maintainable and prevents hard-to-debug mutation and API design issues. - -## Task List - -- Compose complex behavior from small, focused composables -- Use options objects for composables with multiple optional parameters -- Return readonly state when updates must flow through explicit actions -- Keep pure utility functions as plain utilities, not composables -- Organize composable and component code by feature concern, and extract composables when components grow - -## Compose Composables from Smaller Primitives - -**BAD:** -```vue - -``` - -**GOOD:** -```javascript -// composables/useEventListener.js -import { onMounted, onUnmounted, toValue } from 'vue' - -export function useEventListener(target, event, callback) { - onMounted(() => toValue(target).addEventListener(event, callback)) - onUnmounted(() => toValue(target).removeEventListener(event, callback)) -} -``` - -```javascript -// composables/useMouse.js -import { ref } from 'vue' -import { useEventListener } from './useEventListener' - -export function useMouse() { - const x = ref(0) - const y = ref(0) - - useEventListener(window, 'mousemove', (e) => { - x.value = e.pageX - y.value = e.pageY - }) - - return { x, y } -} -``` - -```javascript -// composables/useMouseInElement.js -import { computed } from 'vue' -import { useMouse } from './useMouse' - -export function useMouseInElement(elementRef) { - const { x, y } = useMouse() - - const isOutside = computed(() => { - if (!elementRef.value) return true - const rect = elementRef.value.getBoundingClientRect() - return x.value < rect.left || x.value > rect.right || - y.value < rect.top || y.value > rect.bottom - }) - - return { x, y, isOutside } -} -``` - -## Use Options Object Pattern for Composable Parameters - -**BAD:** -```javascript -export function useFetch(url, method, headers, timeout, retries, immediate) { - // hard to read and easy to misorder -} - -useFetch('/api/users', 'GET', null, 5000, 3, true) -``` - -**GOOD:** -```javascript -export function useFetch(url, options = {}) { - const { - method = 'GET', - headers = {}, - timeout = 30000, - retries = 0, - immediate = true - } = options - - // implementation - return { method, headers, timeout, retries, immediate } -} - -useFetch('/api/users', { - method: 'POST', - timeout: 5000, - retries: 3 -}) -``` - -```typescript -interface UseCounterOptions { - initial?: number - min?: number - max?: number - step?: number -} - -export function useCounter(options: UseCounterOptions = {}) { - const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options - // implementation -} -``` - -## Return Readonly State with Explicit Actions - -**BAD:** -```javascript -export function useCart() { - const items = ref([]) - const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0)) - return { items, total } // any consumer can mutate directly -} - -const { items } = useCart() -items.value.push({ id: 1, price: 10 }) -``` - -**GOOD:** -```javascript -import { ref, computed, readonly } from 'vue' - -export function useCart() { - const _items = ref([]) - - const total = computed(() => - _items.value.reduce((sum, item) => sum + item.price * item.quantity, 0) - ) - - function addItem(product, quantity = 1) { - const existing = _items.value.find(item => item.id === product.id) - if (existing) { - existing.quantity += quantity - return - } - _items.value.push({ ...product, quantity }) - } - - function removeItem(productId) { - _items.value = _items.value.filter(item => item.id !== productId) - } - - return { - items: readonly(_items), - total, - addItem, - removeItem - } -} -``` - -## Keep Utilities as Utilities - -**BAD:** -```javascript -export function useFormatters() { - const formatDate = (date) => new Intl.DateTimeFormat('en-US').format(date) - const formatCurrency = (amount) => - new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount) - return { formatDate, formatCurrency } -} - -const { formatDate } = useFormatters() -``` - -**GOOD:** -```javascript -// utils/formatters.js -export function formatDate(date) { - return new Intl.DateTimeFormat('en-US').format(date) -} - -export function formatCurrency(amount) { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD' - }).format(amount) -} -``` - -```javascript -// composables/useInvoiceSummary.js -import { computed } from 'vue' -import { formatCurrency } from '@/utils/formatters' - -export function useInvoiceSummary(invoiceRef) { - const totalLabel = computed(() => formatCurrency(invoiceRef.value.total)) - return { totalLabel } -} -``` - -## Organize Composable and Component Code by Feature Concern - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -```javascript -// composables/useItems.js -import { ref, onMounted } from 'vue' - -export function useItems() { - const items = ref([]) - const loading = ref(false) - - async function fetchItems() { - loading.value = true - try { - items.value = await api.getItems() - } finally { - loading.value = false - } - } - - onMounted(fetchItems) - return { items, loading, fetchItems } -} -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/directives.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/directives.md deleted file mode 100644 index 8412fbc88..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/directives.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -title: Directive Best Practices -impact: MEDIUM -impactDescription: Custom directives are powerful but easy to misuse; following patterns prevents leaks, invalid usage, and unclear abstractions -type: best-practice -tags: [vue3, directives, custom-directives, composition, typescript] ---- - -# Directive Best Practices - -**Impact: MEDIUM** - Directives are for low-level DOM access. Use them sparingly, keep them side-effect safe, and prefer components or composables when you need stateful or reusable UI behavior. - -## Task List - -- Use directives only when you need direct DOM access -- Do not mutate directive arguments or binding objects -- Clean up timers, listeners, and observers in `unmounted` -- Register directives in ` - - -``` - -## Clean Up Side Effects in `unmounted` - -Any timers, listeners, or observers must be removed to avoid leaks. - -```ts -const vResize = { - mounted(el) { - const observer = new ResizeObserver(() => {}) - observer.observe(el) - el._observer = observer - }, - unmounted(el) { - el._observer?.disconnect() - } -} -``` - -## Prefer Function Shorthand for Single-Hook Directives - -If you only need `mounted`/`updated`, use the function form. - -```ts -const vAutofocus = (el) => el.focus() -``` - -## Use the `v-` Prefix and Script Setup Registration - -```vue - - - -``` - -## Type Custom Directives in TypeScript Projects - -Use `Directive` so `binding.value` is typed, and augment Vue's template types so directives are recognized in SFC templates. - -**BAD:** -```ts -// Untyped directive value and no template type augmentation -export const vHighlight = { - mounted(el, binding) { - el.style.backgroundColor = binding.value - } -} -``` - -**GOOD:** -```ts -import type { Directive } from 'vue' - -type HighlightValue = string - -export const vHighlight = { - mounted(el, binding) { - el.style.backgroundColor = binding.value - } -} satisfies Directive - -declare module 'vue' { - interface ComponentCustomProperties { - vHighlight: typeof vHighlight - } -} -``` - -## Handle SSR with `getSSRProps` - -Directive hooks such as `mounted` and `updated` do not run during SSR. If a directive sets attributes/classes that affect rendered HTML, provide an SSR equivalent via `getSSRProps` to avoid hydration mismatches. - -**BAD:** -```ts -const vTooltip = { - mounted(el, binding) { - el.setAttribute('data-tooltip', binding.value) - el.classList.add('has-tooltip') - } -} -``` - -**GOOD:** -```ts -const vTooltip = { - mounted(el, binding) { - el.setAttribute('data-tooltip', binding.value) - el.classList.add('has-tooltip') - }, - getSSRProps(binding) { - return { - 'data-tooltip': binding.value, - class: 'has-tooltip' - } - } -} -``` - -## Prefer Declarative Templates When Possible - -If a standard attribute or binding works, use it instead of a directive. - -## Decide Between Directives and Components - -Use a directive for DOM-level behavior. Use a component when behavior affects structure, state, or rendering. diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md deleted file mode 100644 index 44f98ff45..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -title: Avoid Excessive Component Abstraction in Large Lists -impact: MEDIUM -impactDescription: Each component instance has memory and render overhead - abstractions multiply this in lists -type: efficiency -tags: [vue3, performance, components, abstraction, lists, optimization] ---- - -# Avoid Excessive Component Abstraction in Large Lists - -**Impact: MEDIUM** - Component instances are more expensive than plain DOM nodes. While abstractions improve code organization, unnecessary nesting creates overhead. In large lists, this overhead multiplies - 100 items with 3 levels of abstraction means 300+ component instances instead of 100. - -Don't avoid abstraction entirely, but be mindful of component depth in frequently-rendered elements like list items. - -## Task List - -- Review list item components for unnecessary wrapper components -- Consider flattening component hierarchies in hot paths -- Use native elements when a component adds no value -- Profile component counts using Vue DevTools -- Focus optimization efforts on the most-rendered components - -**BAD:** -```vue - - - - - - - -``` - -**GOOD:** -```vue - - - - - - - - - -``` - -## When Abstraction Is Still Worth It - -```vue - - - - - - - - - - - - - - - -``` - -## Measuring Component Overhead - -```javascript -// In development, profile component counts -import { onMounted, getCurrentInstance } from 'vue' - -onMounted(() => { - const instance = getCurrentInstance() - let count = 0 - - function countComponents(vnode) { - if (vnode.component) count++ - if (vnode.children) { - vnode.children.forEach(child => { - if (child.component || child.children) countComponents(child) - }) - } - } - - // Use Vue DevTools instead for accurate counts - console.log('Check Vue DevTools Components tab for instance counts') -}) -``` - -## Alternatives to Wrapper Components - -```vue - - - - -{{ content }} - - -
    - -
    - - -``` - -## Impact Calculation - -| List Size | Components per Item | Total Instances | Memory Impact | -|-----------|---------------------|-----------------|---------------| -| 100 items | 1 (flat) | 100 | Baseline | -| 100 items | 3 (nested) | 300 | ~3x memory | -| 100 items | 5 (deeply nested) | 500 | ~5x memory | -| 1000 items | 1 (flat) | 1000 | High | -| 1000 items | 5 (deeply nested) | 5000 | Very High | diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md deleted file mode 100644 index ce5f6880c..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -title: Use v-once and v-memo to Skip Unnecessary Updates -impact: MEDIUM -impactDescription: v-once skips all future updates for static content; v-memo conditionally memoizes subtrees -type: efficiency -tags: [vue3, performance, v-once, v-memo, optimization, directives] ---- - -# Use v-once and v-memo to Skip Unnecessary Updates - -**Impact: MEDIUM** - Vue re-evaluates templates on every reactive change. For content that never changes or changes infrequently, `v-once` and `v-memo` tell Vue to skip updates, reducing render work. - -Use `v-once` for truly static content and `v-memo` for conditionally-static content in lists. - -## Task List - -- Apply `v-once` to elements that use runtime data but never need updating -- Apply `v-memo` to list items that should only update on specific condition changes -- Verify memoized content doesn't need to respond to other state changes -- Profile with Vue DevTools to confirm update skipping - -## v-once: Render Once, Never Update - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - - - -``` - -## v-memo: Conditional Memoization for Lists - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - - - -``` - -## v-memo with Multiple Dependencies - -```vue - - - -``` - -## v-memo with Empty Array = v-once - -```vue - -``` - -## When NOT to Use These Directives - -```vue - -``` - -## Performance Comparison - -| Scenario | Without Directive | With v-once/v-memo | -|----------|-------------------|-------------------| -| Static header, parent re-renders 100x | Re-evaluated 100x | Evaluated 1x | -| 1000 items, selection changes | 1000 items re-render | 2 items re-render | -| Complex child component | Full re-render | Skipped if memoized | - -## Debugging Memoized Components - -```vue - -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-virtualize-large-lists.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-virtualize-large-lists.md deleted file mode 100644 index 78a8a1c63..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/perf-virtualize-large-lists.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -title: Virtualize Large Lists to Avoid DOM Overload -impact: HIGH -impactDescription: Rendering thousands of list items creates excessive DOM nodes, causing slow renders and high memory usage -type: efficiency -tags: [vue3, performance, virtual-list, large-data, dom, optimization] ---- - -# Virtualize Large Lists to Avoid DOM Overload - -**Impact: HIGH** - Rendering all items in a large list (hundreds or thousands) creates massive amounts of DOM nodes. Each node consumes memory, slows down initial render, and makes updates expensive. List virtualization only renders visible items, dramatically improving performance. - -Use a virtualization library when dealing with lists that could exceed 50-100 items, especially if items have complex content. - -## Task List - -- Identify lists that render more than 50-100 items -- Install a virtualization library (vue-virtual-scroller, @tanstack/vue-virtual) -- Replace standard `v-for` with virtualized component -- Ensure list items have consistent or estimable heights -- Test with realistic data volumes during development - -## Recommended Libraries - -| Library | Best For | Notes | -|---------|----------|-------| -| `vue-virtual-scroller` | General use, easy setup | Most popular, good defaults | -| `@tanstack/vue-virtual` | Complex layouts, headless | Framework-agnostic, flexible | -| `vue-virtual-scroll-grid` | Grid layouts | 2D virtualization | -| `vueuc/VVirtualList` | Naive UI projects | Part of Naive UI ecosystem | - -**BAD:** -```vue - - - -``` - -**GOOD:** -```vue - - - - - -``` - -## Using @tanstack/vue-virtual - -```vue - - - - - -``` - -## Dynamic Heights with vue-virtual-scroller - -```vue - - - -``` - -## Performance Comparison - -| Approach | 100 Items | 1,000 Items | 10,000 Items | -|----------|-----------|-------------|--------------| -| Regular v-for | ~100 DOM nodes | ~1,000 DOM nodes | ~10,000 DOM nodes | -| Virtualized | ~20 DOM nodes | ~20 DOM nodes | ~20 DOM nodes | -| Initial render | Fast | Slow | Very slow / crashes | -| Virtualized render | Fast | Fast | Fast | - -## When NOT to Virtualize - -- Lists under 50 items with simple content -- Lists where all items must be accessible to screen readers simultaneously -- Print layouts where all content must render -- SEO-critical content that must be in initial HTML diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/plugins.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/plugins.md deleted file mode 100644 index 190cee822..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/plugins.md +++ /dev/null @@ -1,166 +0,0 @@ ---- -title: Vue Plugin Best Practices -impact: MEDIUM -impactDescription: Incorrect plugin structure or injection key strategy causes install failures, collisions, and unsafe APIs -type: best-practice -tags: [vue3, plugins, provide-inject, typescript, dependency-injection] ---- - -# Vue Plugin Best Practices - -**Impact: MEDIUM** - Vue plugins should follow the `app.use()` contract, expose explicit capabilities, and use collision-safe injection keys. This keeps plugin setup predictable and composable across large apps. - -## Task List - -- Export plugins as an object with `install()` or as an install function -- Use the `app` instance in `install()` to register components/directives/provides -- Type plugin APIs with `Plugin` (and options tuple types when needed) -- Use symbol keys (prefer `InjectionKey`) for `provide/inject` in plugins -- Add a small typed composable wrapper for required injections to fail fast - -## Structure Plugins for `app.use()` - -A Vue plugin must be either: -- An object with `install(app, options?)` -- A function with the same signature - -**BAD:** -```ts -const notAPlugin = { - doSomething() {} -} - -app.use(notAPlugin) -``` - -**GOOD:** -```ts -import type { App } from 'vue' - -interface PluginOptions { - prefix?: string - debug?: boolean -} - -const myPlugin = { - install(app: App, options: PluginOptions = {}) { - const { prefix = 'my', debug = false } = options - - if (debug) { - console.log('Installing myPlugin with prefix:', prefix) - } - - app.provide('myPlugin', { prefix }) - } -} - -app.use(myPlugin, { prefix: 'custom', debug: true }) -``` - -**GOOD:** -```ts -import type { App } from 'vue' - -function simplePlugin(app: App, options?: { message: string }) { - app.config.globalProperties.$greet = () => options?.message ?? 'Hello!' -} - -app.use(simplePlugin, { message: 'Welcome!' }) -``` - -## Register Capabilities Explicitly in `install()` - -Inside `install()`, wire behavior through Vue application APIs: -- `app.component()` for global components -- `app.directive()` for global directives -- `app.provide()` for injectable services and config -- `app.config.globalProperties` for optional global helpers (sparingly) - -**BAD:** -```ts -const uselessPlugin = { - install(app, options) { - const service = createService(options) - } -} -``` - -**GOOD:** -```ts -const usefulPlugin = { - install(app, options) { - const service = createService(options) - app.provide(serviceKey, service) - } -} -``` - -## Type Plugin Contracts - -Use Vue's `Plugin` type to keep install signatures and options type-safe. - -```ts -import type { App, Plugin } from 'vue' - -interface MyOptions { - apiKey: string -} - -const myPlugin: Plugin<[MyOptions]> = { - install(app: App, options: MyOptions) { - app.provide(apiKeyKey, options.apiKey) - } -} -``` - -## Use Symbol Injection Keys in Plugins - -String keys can collide (`'http'`, `'config'`, `'i18n'`). Use symbol keys with `InjectionKey` so injections are unique and typed. - -**BAD:** -```ts -export default { - install(app) { - app.provide('http', axios) - app.provide('config', appConfig) - } -} -``` - -**GOOD:** -```ts -import type { InjectionKey } from 'vue' -import type { AxiosInstance } from 'axios' - -interface AppConfig { - apiUrl: string - timeout: number -} - -export const httpKey: InjectionKey = Symbol('http') -export const configKey: InjectionKey = Symbol('appConfig') - -export default { - install(app) { - app.provide(httpKey, axios) - app.provide(configKey, { apiUrl: '/api', timeout: 5000 }) - } -} -``` - -## Provide Required Injection Helpers - -Wrap required injections in composables that throw clear setup errors. - -```ts -import { inject } from 'vue' -import { authKey, type AuthService } from '@/injection-keys' - -export function useAuth(): AuthService { - const auth = inject(authKey) - if (!auth) { - throw new Error('Auth plugin not installed. Did you forget app.use(authPlugin)?') - } - return auth -} -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/reactivity.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/reactivity.md deleted file mode 100644 index 4cf0ad39c..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/reactivity.md +++ /dev/null @@ -1,344 +0,0 @@ ---- -title: Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch) -impact: MEDIUM -impactDescription: Clear reactivity choices keep state predictable and reduce unnecessary updates in Vue 3 apps -type: efficiency -tags: [vue3, reactivity, ref, reactive, shallowRef, computed, watch, watchEffect, external-state, best-practice] ---- - -# Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch) - -**Impact: MEDIUM** - Choose the right reactive primitive first, derive with `computed`, and use watchers only for side effects. - -This reference covers the core reactivity decisions for local state, external data, derived values, and effects. - -## Task List - -- Declare reactive state correctly - - Always use `shallowRef()` instead of `ref()` for primitive values - - Choose the correct reactive declaration method for objects/arrays/map/set -- Follow best practices for `reactive` - - Avoid destructuring from `reactive()` directly - - Watch correctly for `reactive` -- Follow best practices for `computed` - - Prefer `computed` over watcher-assigned derived refs - - Keep filtered/sorted derivations out of templates - - Use `computed` for reusable class/style logic - - Keep computed getters pure (no side effects) and put side effects in watchers -- Follow best practices for watchers - - Use `immediate: true` instead of duplicate initial calls - - Clean up async effects for watchers - -## Declare reactive state correctly - -### Always use `shallowRef()` instead of `ref()` for primitive values (string, number, boolean, null, etc.) for better performance. - -**Incorrect:** -```ts -import { ref } from 'vue' -const count = ref(0) -``` - -**Correct:** -```ts -import { shallowRef } from 'vue' -const count = shallowRef(0) -``` - -### Choose the correct reactive declaration method for objects/arrays/map/set - -Use `ref()` when you often **replace the entire value** (`state.value = newObj`) and still want deep reactivity inside it, usually used for: - -- Frequently reassigned state (replace fetched object/list, reset to defaults, switch presets). -- Composable return values where updates happen mostly via `.value` reassignment. - -Use `reactive()` when you mainly **mutate properties** and full replacement is uncommon, usually used for: - -- “Single state object” patterns (stores/forms): `state.count++`, `state.items.push(...)`, `state.user.name = ...`. -- Situations where you want to avoid `.value` and update nested fields in place. - -```ts -import { reactive } from 'vue' - -const state = reactive({ - count: 0, - user: { name: 'Alice', age: 30 } -}) - -state.count++ // ✅ reactive -state.user.age = 31 // ✅ reactive -// ❌ avoid replacing the reactive object reference: -// state = reactive({ count: 1 }) -``` - -Use `shallowRef()` when the value is **opaque / should not be proxied** (class instances, external library objects, very large nested data) and you only want updates to trigger when you **replace** `state.value` (no deep tracking), usually used for: - -- Storing external instances/handles (SDK clients, class instances) without Vue proxying internals. -- Large data where you update by replacing the root reference (immutable-style updates). - -```ts -import { shallowRef } from 'vue' - -const user = shallowRef({ name: 'Alice', age: 30 }) - -user.value.age = 31 // ❌ not reactive -user.value = { name: 'Bob', age: 25 } // ✅ triggers update -``` - -Use `shallowReactive()` when you want **only top-level properties** reactive; nested objects remain raw, usually used for: - -- Container objects where only top-level keys change and nested payloads should stay unmanaged/unproxied. -- Mixed structures where Vue tracks the wrapper object, but not deeply nested or foreign objects. - -```ts -import { shallowReactive } from 'vue' - -const state = shallowReactive({ - count: 0, - user: { name: 'Alice', age: 30 } -}) - -state.count++ // ✅ reactive -state.user.age = 31 // ❌ not reactive -``` - -## Best practices for `reactive` - -### Avoid destructuring from `reactive()` directly - -**BAD:** - -```ts -import { reactive } from 'vue' - -const state = reactive({ count: 0 }) -const { count } = state // ❌ disconnected from reactivity -``` - -### Watch correctly for reactive - -**BAD:** - -passing a non-getter value into `watch()` - -```ts -import { reactive, watch } from 'vue' - -const state = reactive({ count: 0 }) - -// ❌ watch expects a getter, ref, reactive object, or array of these -watch(state.count, () => { /* ... */ }) -``` - -**GOOD:** - -preserve reactivity with `toRefs()` and use a getter for `watch()` - -```ts -import { reactive, toRefs, watch } from 'vue' - -const state = reactive({ count: 0 }) -const { count } = toRefs(state) // ✅ count is a ref - -watch(count, () => { /* ... */ }) // ✅ -watch(() => state.count, () => { /* ... */ }) // ✅ -``` - -## Best practices for `computed` - -### Prefer `computed` over watcher-assigned derived refs - -**BAD:** -```ts -import { ref, watchEffect } from 'vue' - -const items = ref([{ price: 10 }, { price: 20 }]) -const total = ref(0) - -watchEffect(() => { - total.value = items.value.reduce((sum, item) => sum + item.price, 0) -}) -``` - -**GOOD:** -```ts -import { ref, computed } from 'vue' - -const items = ref([{ price: 10 }, { price: 20 }]) -const total = computed(() => - items.value.reduce((sum, item) => sum + item.price, 0) -) -``` - -### Keep filtered/sorted derivations out of templates - -**BAD:** -```vue - - - -``` - -**GOOD:** -```vue - - - -``` - -### Use `computed` for reusable class/style logic - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - - - -``` - -### Keep computed getters pure (no side effects) and put side effects in watchers instead - -A computed getter should only derive a value. No mutation, no API calls, no storage writes, no event emits. -([Reference](https://vuejs.org/guide/essentials/computed.html#best-practices)) - -**BAD:** - -side effects inside computed - -```ts -const count = ref(0) - -const doubled = computed(() => { - // ❌ side effect - if (count.value > 10) console.warn('Too big!') - return count.value * 2 -}) -``` - -**GOOD:** - -pure computed + `watch()` for side effects - -```ts -const count = ref(0) -const doubled = computed(() => count.value * 2) - -watch(count, (value) => { - if (value > 10) console.warn('Too big!') -}) -``` - -## Best practices for watchers - -### Use `immediate: true` instead of duplicate initial calls - -**BAD:** -```ts -import { ref, watch, onMounted } from 'vue' - -const userId = ref(1) - -function loadUser(id) { - // ... -} - -onMounted(() => loadUser(userId.value)) -watch(userId, (id) => loadUser(id)) -``` - -**GOOD:** -```ts -import { ref, watch } from 'vue' - -const userId = ref(1) - -watch( - userId, - (id) => loadUser(id), - { immediate: true } -) -``` - -### Clean up async effects for watchers - -When reacting to rapid changes (search boxes, filters), cancel the previous request. - -**GOOD:** - -```ts -const query = ref('') -const results = ref([]) - -watch(query, async (q, _prev, onCleanup) => { - const controller = new AbortController() - onCleanup(() => controller.abort()) - - const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { - signal: controller.signal, - }) - - results.value = await res.json() -}) -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/render-functions.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/render-functions.md deleted file mode 100644 index b64942c57..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/render-functions.md +++ /dev/null @@ -1,201 +0,0 @@ ---- -title: Render Function Patterns and Performance -impact: MEDIUM -impactDescription: Render functions require explicit patterns for lists, events, v-model, and performance to stay correct and maintainable -type: best-practice -tags: [vue3, render-function, h, v-model, directives, performance, jsx] ---- - -# Render Function Patterns and Performance - -**Impact: MEDIUM** - Render functions are powerful but opt out of template compiler optimizations. Use them intentionally and apply the key patterns below to keep output correct and performant. - -## Task List - -- Prefer templates; use render functions only when templates cannot express the logic -- Always add stable keys when rendering lists with `h()`/JSX -- Use `withModifiers` / `withKeys` for event modifiers -- Implement `v-model` via `modelValue` + `onUpdate:modelValue` -- Apply custom directives with `withDirectives` -- Use functional components for stateless presentational UI - -## Prefer templates over render functions - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - - - -``` - -## Always add keys for list rendering - -**BAD:** -```javascript -import { h, ref } from 'vue' - -export default { - setup() { - const items = ref([{ id: 1, name: 'Apple' }]) - - return () => h('ul', - items.value.map(item => h('li', item.name)) - ) - } -} -``` - -**GOOD:** -```javascript -import { h, ref } from 'vue' - -export default { - setup() { - const items = ref([{ id: 1, name: 'Apple' }]) - - return () => h('ul', - items.value.map(item => h('li', { key: item.id }, item.name)) - ) - } -} -``` - -## Use `withModifiers` / `withKeys` for event modifiers - -**BAD:** -```javascript -import { h } from 'vue' - -export default { - setup() { - const handleClick = (e) => { - e.stopPropagation() - e.preventDefault() - } - - return () => h('button', { onClick: handleClick }, 'Click') - } -} -``` - -**GOOD:** -```javascript -import { h, withModifiers, withKeys } from 'vue' - -export default { - setup() { - const handleClick = () => {} - const handleEnter = () => {} - - return () => h('div', [ - h('button', { - onClick: withModifiers(handleClick, ['stop', 'prevent']) - }, 'Click'), - h('input', { - onKeyup: withKeys(handleEnter, ['enter']) - }) - ]) - } -} -``` - -## Implement `v-model` explicitly - -**BAD:** -```javascript -import { h, ref } from 'vue' -import CustomInput from './CustomInput.vue' - -export default { - setup() { - const text = ref('') - return () => h(CustomInput, { modelValue: text.value }) - } -} -``` - -**GOOD:** -```javascript -import { h, ref } from 'vue' -import CustomInput from './CustomInput.vue' - -export default { - setup() { - const text = ref('') - return () => h(CustomInput, { - modelValue: text.value, - 'onUpdate:modelValue': (value) => { text.value = value } - }) - } -} -``` - -## Use `withDirectives` for custom directives - -**BAD:** -```javascript -import { h } from 'vue' - -const vFocus = { mounted: (el) => el.focus() } - -export default { - setup() { - return () => h('input', { 'v-focus': true }) - } -} -``` - -**GOOD:** -```javascript -import { h, withDirectives } from 'vue' - -const vFocus = { mounted: (el) => el.focus() } - -export default { - setup() { - return () => withDirectives(h('input'), [[vFocus]]) - } -} -``` - -## Prefer functional components for stateless UI - -**BAD:** -```javascript -import { h } from 'vue' - -export default { - setup() { - return () => h('span', { class: 'badge' }, 'New') - } -} -``` - -**GOOD:** -```javascript -import { h } from 'vue' - -function Badge(props, { slots }) { - return h('span', { class: 'badge' }, slots.default?.()) -} - -Badge.props = ['variant'] - -export default Badge -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/sfc.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/sfc.md deleted file mode 100644 index d1c3981c7..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/sfc.md +++ /dev/null @@ -1,310 +0,0 @@ ---- -title: Single-File Component Structure, Styling, and Template Patterns -impact: MEDIUM -impactDescription: Consistent SFC structure and styling choices improve maintainability, tooling support, and render performance -type: best-practice -tags: [vue3, sfc, scoped-css, styles, build-tools, performance, template, v-html, v-for, computed, v-if, v-show] ---- - -# Single-File Component Structure, Styling, and Template Patterns - -**Impact: MEDIUM** - Using SFCs with consistent structure and performant styling keeps components easier to maintain and avoids unnecessary render overhead. - -## Task List - -- Use `.vue` SFCs instead of separate `.js`/`.ts` and `.css` files for components -- Colocate template, script, and styles in the same SFC by default -- Use PascalCase for component names in templates and filenames -- Prefer component-scoped styles -- Prefer class selectors (not element selectors) in scoped CSS for performance -- Access DOM / component refs with `useTemplateRef()` in Vue 3.5+ -- Use camelCase keys in `:style` bindings for consistency and IDE support -- Use `v-for` and `v-if` correctly -- Never use `v-html` with untrusted/user-provided content -- Choose `v-if` vs `v-show` based on toggle frequency and initial render cost - -## Colocate template, script, and styles - -**BAD:** -``` -components/ -├── UserCard.vue -├── UserCard.js -└── UserCard.css -``` - -**GOOD:** -```vue - - - - - - -``` - -## Use PascalCase for component names - -**BAD:** -```vue - - - -``` - -**GOOD:** -```vue - - - -``` - -## Best practices for ` -``` - -**GOOD:** - -```vue - -``` - -**GOOD:** - -```css -/* src/assets/main.css */ -/* ✅ resets, tokens, typography, app-wide rules */ -:root { --radius: 999px; } -``` - -### Use class selectors in scoped CSS - -**BAD:** -```vue - - - -``` - -**GOOD:** -```vue - - - -``` - -## Access DOM / component refs with `useTemplateRef()` - -For Vue 3.5+: use `useTemplateRef()` to access template refs. - -```vue - - - -``` - -## Use camelCase in `:style` bindings - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` - -## Use `v-for` and `v-if` correctly - -### Always provide a stable `:key` - -- Prefer primitive keys (`string | number`). -- Avoid using objects as keys. - -**GOOD:** - -```vue -
  • - -
  • -``` - -### Avoid `v-if` and `v-for` on the same element - -It leads to unclear intent and unnecessary work. -([Reference](https://vuejs.org/guide/essentials/list.html#v-for-with-v-if)) - -**To filter items** -**BAD:** - -```vue -
  • - {{ user.name }} -
  • -``` - -**GOOD:** - -```vue - - - -``` - -**To conditionally show/hide the entire list** -**GOOD:** - -```vue -
      -
    • - {{ user.name }} -
    • -
    -``` - -## Never render untrusted HTML with `v-html` - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - - - -``` - -## Choose `v-if` vs `v-show` by toggle behavior - -**BAD:** -```vue - -``` - -**GOOD:** -```vue - -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/state-management.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/state-management.md deleted file mode 100644 index 02423ab24..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/state-management.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -title: State Management Strategy -impact: HIGH -impactDescription: Choosing the wrong store pattern can cause SSR request leaks, brittle mutation flows, and poor scaling -type: best-practice -tags: [vue3, state-management, pinia, composables, ssr, vueuse] ---- - -# State Management Strategy - -**Impact: HIGH** - Use the lightest state solution that fits your app architecture. SPA-only apps can use lightweight global composables, while SSR/Nuxt apps should default to Pinia for request-safe isolation and predictable tooling. - -## Task List - -- Keep state local first, then promote to shared/global only when needed -- Use singleton composables only in non-SSR applications -- Expose global state as readonly and mutate through explicit actions -- Prefer Pinia for SSR/Nuxt, large apps, and advanced debugging/plugin needs -- Avoid exporting mutable module-level reactive state directly - -## Choose the Lightest Store Approach - -- **Feature composable:** Default for reusable logic with local/feature-level state. -- **Singleton composable or VueUse `createGlobalState`:** Small non-SSR apps needing shared app state. -- **Pinia:** SSR/Nuxt apps, medium-to-large apps, and cases requiring DevTools, plugins, or action tracing. - -## Avoid Exporting Mutable Module State - -**BAD:** -```ts -// store/cart.ts -import { reactive } from 'vue' - -export const cart = reactive({ - items: [] as Array<{ id: string; qty: number }> -}) -``` - -**GOOD:** -```ts -// composables/useCartStore.ts -import { reactive, readonly } from 'vue' - -let _store: ReturnType | null = null - -function createCartStore() { - const state = reactive({ - items: [] as Array<{ id: string; qty: number }> - }) - - function addItem(id: string, qty = 1) { - const existing = state.items.find((item) => item.id === id) - if (existing) { - existing.qty += qty - return - } - state.items.push({ id, qty }) - } - - return { - state: readonly(state), - addItem - } -} - -export function useCartStore() { - if (!_store) _store = createCartStore() - return _store -} -``` - -## Do Not Use Runtime Singletons in SSR - -Module singletons live for the runtime lifetime. In SSR this can leak state between requests. - -**BAD:** -```ts -// shared singleton reused across requests -const cartStore = useCartStore() - -export function useServerCart() { - return cartStore -} -``` - -**GOOD:** - -> `pinia` dependency required. - -```ts -// stores/cart.ts -import { defineStore } from 'pinia' - -export const useCartStore = defineStore('cart', { - state: () => ({ - items: [] as Array<{ id: string; qty: number }> - }), - actions: { - addItem(id: string, qty = 1) { - const existing = this.items.find((item) => item.id === id) - if (existing) { - existing.qty += qty - return - } - this.items.push({ id, qty }) - } - } -}) -``` - -## Use `createGlobalState` for Small SPA Global State - -> `@vueuse/core` dependency required. - -If the app is non-SSR and already uses VueUse, `createGlobalState` removes singleton boilerplate. - -```ts -import { createGlobalState } from '@vueuse/core' -import { computed, ref } from 'vue' - -export const useAuthState = createGlobalState(() => { - const token = ref(null) - const isAuthenticated = computed(() => token.value !== null) - - function setToken(next: string | null) { - token.value = next - } - - return { - token, - isAuthenticated, - setToken - } -}) -``` diff --git a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/updated-hook-performance.md b/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/updated-hook-performance.md deleted file mode 100644 index 6375e862b..000000000 --- a/packages/kit/src/message/test/fixtures/skills/vue-best-practices/references/updated-hook-performance.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -title: Avoid Expensive Operations in Updated Hook -impact: MEDIUM -impactDescription: Heavy computations in updated hook cause performance bottlenecks and potential infinite loops -type: capability -tags: [vue3, vue2, lifecycle, updated, performance, optimization, reactivity] ---- - -# Avoid Expensive Operations in Updated Hook - -**Impact: MEDIUM** - The `updated` hook runs after every reactive state change that causes a re-render. Placing expensive operations, API calls, or state mutations here can cause severe performance degradation, infinite loops, and dropped frames below the optimal 60fps threshold. - -Use `updated`/`onUpdated` sparingly for post-DOM-update operations that cannot be handled by watchers or computed properties. For most reactive data handling, prefer watchers (`watch`/`watchEffect`) which provide more control over what triggers the callback. - -## Task List - -- Never perform API calls in updated hook -- Never mutate reactive state inside updated (causes infinite loops) -- Use conditional checks to verify updates are relevant before acting -- Prefer `watch` or `watchEffect` for reacting to specific data changes -- Use throttling/debouncing if updated operations are expensive -- Reserve updated for low-level DOM synchronization tasks - -**BAD:** -```javascript -// BAD: API call in updated - fires on every re-render -export default { - data() { - return { items: [], lastUpdate: null } - }, - updated() { - // This runs after every single state change! - fetch('/api/sync', { - method: 'POST', - body: JSON.stringify(this.items) - }) - } -} -``` - -```javascript -// BAD: State mutation in updated - infinite loop -export default { - data() { - return { renderCount: 0 } - }, - updated() { - // This causes another update, which triggers updated again! - this.renderCount++ // Infinite loop - } -} -``` - -```javascript -// BAD: Heavy computation on every update -export default { - updated() { - // Expensive operation runs on every keystroke, every state change - this.processedData = this.heavyComputation(this.rawData) - this.analytics = this.calculateMetrics(this.allData) - } -} -``` - -**GOOD:** -```javascript -import debounce from 'lodash-es/debounce' - -// GOOD: Use watcher for specific data changes -export default { - data() { - return { items: [] } - }, - watch: { - // Only fires when items actually changes - items: { - handler(newItems) { - this.syncToServer(newItems) - }, - deep: true - } - }, - methods: { - syncToServer: debounce(function(items) { - fetch('/api/sync', { - method: 'POST', - body: JSON.stringify(items) - }) - }, 500) - } -} -``` - -```vue - - -``` - -```javascript -// GOOD: Conditional check in updated hook -export default { - data() { - return { - content: '', - lastSyncedContent: '' - } - }, - updated() { - // Only act if specific condition is met - if (this.content !== this.lastSyncedContent) { - this.syncContent() - this.lastSyncedContent = this.content - } - }, - methods: { - syncContent: debounce(function() { - // Sync logic - }, 300) - } -} -``` - -## Valid Use Cases for Updated Hook - -```javascript -// GOOD: Low-level DOM synchronization -export default { - updated() { - // Sync third-party library with Vue's DOM - this.thirdPartyWidget.refresh() - - // Update scroll position after content change - this.$nextTick(() => { - this.maintainScrollPosition() - }) - } -} -``` - -## Prefer Computed Properties for Derived Data - -```javascript -// BAD: Calculating derived data in updated -export default { - data() { - return { numbers: [1, 2, 3, 4, 5] } - }, - updated() { - this.sum = this.numbers.reduce((a, b) => a + b, 0) // Causes another update! - } -} - -// GOOD: Use computed property instead -export default { - data() { - return { numbers: [1, 2, 3, 4, 5] } - }, - computed: { - sum() { - return this.numbers.reduce((a, b) => a + b, 0) - } - } -} -``` diff --git a/packages/kit/src/message/test/fixtures/skills/weather/SKILL.md b/packages/kit/src/message/test/fixtures/skills/weather/SKILL.md deleted file mode 100644 index 7901e12ec..000000000 --- a/packages/kit/src/message/test/fixtures/skills/weather/SKILL.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -name: weather -description: "Get current weather, rain, temperature, and forecasts for locations or travel planning." -homepage: https://wttr.in/:help -metadata: - { - "openclaw": - { - "emoji": "☔", - "requires": { "bins": ["curl"] }, - "install": - [ - { - "id": "brew", - "kind": "brew", - "formula": "curl", - "bins": ["curl"], - "label": "Install curl (brew)", - }, - ], - }, - } ---- - -# Weather Skill - -Get current weather conditions and forecasts. - -## When to Use - -✅ **USE this skill when:** - -- "What's the weather?" -- "Will it rain today/tomorrow?" -- "Temperature in [city]" -- "Weather forecast for the week" -- Travel planning weather checks - -## When NOT to Use - -❌ **DON'T use this skill when:** - -- Historical weather data → use weather archives/APIs -- Climate analysis or trends → use specialized data sources -- Hyper-local microclimate data → use local sensors -- Severe weather alerts → check official NWS sources -- Aviation/marine weather → use specialized services (METAR, etc.) - -## Location - -Always include a city, region, or airport code in weather queries. - -## Commands - -### Current Weather - -```bash -# One-line summary -curl "wttr.in/London?format=3" - -# Detailed current conditions -curl "wttr.in/London?0" - -# Specific city -curl "wttr.in/New+York?format=3" -``` - -### Forecasts - -```bash -# 3-day forecast -curl "wttr.in/London" - -# Week forecast -curl "wttr.in/London?format=v2" - -# Specific day (0=today, 1=tomorrow, 2=day after) -curl "wttr.in/London?1" -``` - -### Format Options - -```bash -# One-liner -curl "wttr.in/London?format=%l:+%c+%t+%w" - -# JSON output -curl "wttr.in/London?format=j1" - -# PNG image -curl "wttr.in/London.png" -``` - -### Format Codes - -- `%c` — Weather condition emoji -- `%t` — Temperature -- `%f` — "Feels like" -- `%w` — Wind -- `%h` — Humidity -- `%p` — Precipitation -- `%l` — Location - -## Quick Responses - -**"What's the weather?"** - -```bash -curl -s "wttr.in/London?format=%l:+%c+%t+(feels+like+%f),+%w+wind,+%h+humidity" -``` - -**"Will it rain?"** - -```bash -curl -s "wttr.in/London?format=%l:+%c+%p" -``` - -**"Weekend forecast"** - -```bash -curl "wttr.in/London?format=v2" -``` - -## Notes - -- No API key needed (uses wttr.in) -- Rate limited; don't spam requests -- Works for most global cities -- Supports airport codes: `curl wttr.in/ORD` diff --git a/packages/kit/src/message/skills/browserSkillLoader.ts b/packages/kit/src/skills/browserSkillLoader.ts similarity index 93% rename from packages/kit/src/message/skills/browserSkillLoader.ts rename to packages/kit/src/skills/browserSkillLoader.ts index 3604170af..c92ae778e 100644 --- a/packages/kit/src/message/skills/browserSkillLoader.ts +++ b/packages/kit/src/skills/browserSkillLoader.ts @@ -1,17 +1,17 @@ import type { SkillFile } from './types' import { isTextSkillFilePath, normalizeSkillPath } from './utils' -type BrowserFile = Pick & { +export type BrowserFile = Pick & { webkitRelativePath?: string } -type BrowserFileHandle = { +export type BrowserFileHandle = { kind: 'file' name: string getFile: () => Promise } -type BrowserDirectoryHandle = { +export type BrowserDirectoryHandle = { kind: 'directory' name: string entries: () => AsyncIterable<[string, BrowserFileHandle | BrowserDirectoryHandle]> diff --git a/packages/kit/src/skills/compiler.ts b/packages/kit/src/skills/compiler.ts new file mode 100644 index 000000000..6f95a05c3 --- /dev/null +++ b/packages/kit/src/skills/compiler.ts @@ -0,0 +1,250 @@ +import type { ChatCompletionFunctionTool } from 'openai/resources' +import type { BasePluginContext, BeforeRequestContext } from '../message/types' +import type { RuntimeTool, ToolProviderItem } from '../message/plugins/toolPlugin' +import type { SkillDefinition, SkillFileResource, SkillRuntimeContext } from './types' + +export interface SkillCompilerState { + /** + * 当前 turn 的 skill 定义。 + */ + skills: SkillDefinition[] + /** + * 当前 turn 的 skill 名称。便于展示、日志和序列化。 + */ + skillNames: string[] + /** + * 当前 turn 的运行时工具。 + */ + runtimeTools?: RuntimeTool[] +} + +export const uniqueSkills = (skills: SkillDefinition[]) => { + const result: SkillDefinition[] = [] + const names = new Set() + + for (const skill of skills) { + if (names.has(skill.name)) { + continue + } + + names.add(skill.name) + result.push(skill) + } + + return result +} + +const skillFileToolNames = { + listSkillFiles: 'list_skill_files', + readSkillFile: 'read_skill_file', +} as const + +const skillFileTools: Array = [ + { + type: 'function', + function: { + name: skillFileToolNames.listSkillFiles, + description: 'List files available from the current skills.', + parameters: { + type: 'object', + properties: { + skillName: { + type: 'string', + description: 'Optional skill name. When omitted, files from all current skills are listed.', + }, + }, + additionalProperties: false, + }, + }, + }, + { + type: 'function', + function: { + name: skillFileToolNames.readSkillFile, + description: 'Read a file from a current skill by skill name and relative path.', + parameters: { + type: 'object', + properties: { + skillName: { + type: 'string', + description: 'Skill name that owns the file.', + }, + path: { + type: 'string', + description: 'File path relative to the skill root.', + }, + }, + required: ['skillName', 'path'], + additionalProperties: false, + }, + }, + }, +] + +const hasSkillFiles = (skills: SkillDefinition[]) => skills.some((skill) => Boolean(skill.files?.length)) + +const getSkillFileSummary = (skillName: string, file: SkillFileResource) => ({ + skillName, + id: file.id, + path: file.path, + kind: file.kind, + mimeType: file.mimeType, + size: file.size, + lastModified: file.lastModified, +}) + +const parseSkillToolArguments = (toolCall: Parameters[0]): Record => { + const rawArguments = toolCall.function.arguments + + if (!rawArguments) { + return {} + } + + try { + const parsed = JSON.parse(rawArguments) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } +} + +export const createSkillFileRuntimeTools = (skills: SkillDefinition[]): RuntimeTool[] => { + if (!hasSkillFiles(skills)) { + return [] + } + + const findSkill = (skillName?: unknown) => { + if (typeof skillName !== 'string' || !skillName) { + return undefined + } + + return skills.find((skill) => skill.name === skillName) + } + + return [ + { + tool: skillFileTools[0], + handler: (toolCall) => { + const toolArguments = parseSkillToolArguments(toolCall) + const skill = findSkill(toolArguments.skillName) + const skillList = skill ? [skill] : skills + + return { + files: skillList.flatMap((currentSkill) => + (currentSkill.files ?? []).map((file) => getSkillFileSummary(currentSkill.name, file)), + ), + } + }, + }, + { + tool: skillFileTools[1], + handler: (toolCall) => { + const toolArguments = parseSkillToolArguments(toolCall) + const skill = findSkill(toolArguments.skillName) + const path = typeof toolArguments.path === 'string' ? toolArguments.path : undefined + + if (!skill) { + return { error: 'skill_not_found' } + } + + if (!path) { + return { error: 'file_path_required', skillName: skill.name } + } + + const file = skill.files?.find((skillFile) => skillFile.path === path) + if (!file) { + return { error: 'file_not_found', skillName: skill.name, path } + } + + if (file.kind === 'binary') { + return { + error: 'binary_file_not_readable', + file: getSkillFileSummary(skill.name, file), + } + } + + return { + file: getSkillFileSummary(skill.name, file), + content: file.content, + } + }, + }, + ] +} + +export const createSkillCompilerState = (skills: SkillDefinition[]): SkillCompilerState => { + const uniqueSkillList = uniqueSkills(skills) + const runtimeTools = createSkillFileRuntimeTools(uniqueSkillList) + + return { + skills: uniqueSkillList, + skillNames: uniqueSkillList.map((skill) => skill.name), + runtimeTools: runtimeTools.length ? runtimeTools : undefined, + } +} + +const resolveSkillInstructions = async (skill: SkillDefinition, context: SkillRuntimeContext) => { + if (!skill.instructions) { + return '' + } + + const instructions = typeof skill.instructions === 'function' ? await skill.instructions(context) : skill.instructions + return instructions.trim() +} + +const resolveSkillTools = async (skill: SkillDefinition, context: SkillRuntimeContext) => { + if (!skill.tools) { + return [] + } + + return typeof skill.tools === 'function' ? await skill.tools(context) : skill.tools +} + +export const compileSkillInstructions = async ( + state: Pick, + context: BeforeRequestContext, +) => { + const instructions: string[] = [] + + for (const skill of state.skills) { + const runtimeContext: SkillRuntimeContext = { + ...context, + skill, + skills: state.skills, + } + + const instruction = await resolveSkillInstructions(skill, runtimeContext) + if (instruction) { + instructions.push(`## ${skill.name}\n\n${instruction}`) + } + } + + if (instructions.length > 0) { + context.requestBody.messages = [ + { + role: 'system', + content: ['Apply these skill instructions when generating the response.', ...instructions].join('\n\n'), + }, + ...context.requestBody.messages, + ] + } +} + +export const compileSkillTools = async ( + state: Pick, + context: BasePluginContext, +): Promise => { + const skillTools: ChatCompletionFunctionTool[] = [] + + for (const skill of state.skills) { + const runtimeContext: SkillRuntimeContext = { + ...context, + skill, + skills: state.skills, + } + + skillTools.push(...(await resolveSkillTools(skill, runtimeContext))) + } + + return [...(state.runtimeTools ?? []), ...skillTools] +} diff --git a/packages/kit/src/message/skills/fsSkillLoader.ts b/packages/kit/src/skills/fsSkillLoader.ts similarity index 100% rename from packages/kit/src/message/skills/fsSkillLoader.ts rename to packages/kit/src/skills/fsSkillLoader.ts diff --git a/packages/kit/src/skills/index.ts b/packages/kit/src/skills/index.ts new file mode 100644 index 000000000..3d2bec90d --- /dev/null +++ b/packages/kit/src/skills/index.ts @@ -0,0 +1,25 @@ +export { + compileSkillInstructions, + compileSkillTools, + createSkillCompilerState, + createSkillFileRuntimeTools, + uniqueSkills, +} from './compiler' +export type { SkillCompilerState } from './compiler' +export { loadSkillFilesFromDirectoryHandle, loadSkillFilesFromFileList } from './browserSkillLoader' +export type { BrowserDirectoryHandle, BrowserFile, BrowserFileHandle } from './browserSkillLoader' +export { loadSkillFilesFromFs } from './fsSkillLoader' +export type { FsSkillFileLoaderOptions } from './fsSkillLoader' +export { SkillLoader } from './skillLoader' +export type { LoadedSkill, SkillLoaderOptions, SkillLoaderWarning } from './skillLoader' +export type { + BaseSkillFile, + BinarySkillFile, + SkillDefinition, + SkillFile, + SkillFileKind, + SkillFileResource, + SkillRuntimeContext, + TextSkillFile, +} from './types' +export { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' diff --git a/packages/kit/src/message/skills/skillLoader.ts b/packages/kit/src/skills/skillLoader.ts similarity index 98% rename from packages/kit/src/message/skills/skillLoader.ts rename to packages/kit/src/skills/skillLoader.ts index c985b0eb0..edbd966d4 100644 --- a/packages/kit/src/message/skills/skillLoader.ts +++ b/packages/kit/src/skills/skillLoader.ts @@ -1,7 +1,6 @@ import type { ChatCompletionFunctionTool } from 'openai/resources' import { parse as parseYaml } from 'yaml' -import type { SkillDefinition } from '../plugins/skillPlugin' -import type { SkillFile, SkillFileResource } from './types' +import type { SkillDefinition, SkillFile, SkillFileResource } from './types' import { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' export interface SkillLoaderWarning { diff --git a/packages/kit/src/skills/test/.gitignore b/packages/kit/src/skills/test/.gitignore new file mode 100644 index 000000000..ceddaa37f --- /dev/null +++ b/packages/kit/src/skills/test/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/packages/kit/src/message/test/skillLoader.test.ts b/packages/kit/src/skills/test/skillLoader.test.ts similarity index 89% rename from packages/kit/src/message/test/skillLoader.test.ts rename to packages/kit/src/skills/test/skillLoader.test.ts index a17fbb6d4..8ae32a8e1 100644 --- a/packages/kit/src/message/test/skillLoader.test.ts +++ b/packages/kit/src/skills/test/skillLoader.test.ts @@ -1,11 +1,11 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { loadSkillFilesFromFs } from '../skills/fsSkillLoader' -import { SkillLoader } from '../skills/skillLoader' +import { loadSkillFilesFromFs } from '../fsSkillLoader' +import { SkillLoader } from '../skillLoader' describe('SkillLoader', () => { it('loads weather skill directory as SkillDefinition', async () => { - const skillDirectory = fileURLToPath(new URL('./fixtures/skills/weather', import.meta.url)) + const skillDirectory = fileURLToPath(new URL('./.cache/weather', import.meta.url)) const files = await loadSkillFilesFromFs(skillDirectory) const loadedSkill = new SkillLoader().load(files) const { skill } = loadedSkill @@ -18,7 +18,7 @@ describe('SkillLoader', () => { }) it('loads multi-file skill references as files', async () => { - const skillDirectory = fileURLToPath(new URL('./fixtures/skills/vue-best-practices', import.meta.url)) + const skillDirectory = fileURLToPath(new URL('./.cache/vue-best-practices', import.meta.url)) const files = await loadSkillFilesFromFs(skillDirectory) const loadedSkill = new SkillLoader().load(files) const { skill } = loadedSkill diff --git a/packages/kit/src/message/test/skillPlugin.test.ts b/packages/kit/src/skills/test/skillPlugin.test.ts similarity index 68% rename from packages/kit/src/message/test/skillPlugin.test.ts rename to packages/kit/src/skills/test/skillPlugin.test.ts index 95a2b7c41..07e91ec79 100644 --- a/packages/kit/src/message/test/skillPlugin.test.ts +++ b/packages/kit/src/skills/test/skillPlugin.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { createNativeMessageAdapter } from '../adapters/native' -import { createMessageEngine } from '../core/engine' -import { lengthPlugin, skillPlugin, thinkingPlugin, toolPlugin } from '../plugins' -import type { CreateMessageEngineOptions, MessageRequestBody } from '../types' -import { mockResponseProvider } from './mockResponseProvider' +import { createNativeMessageAdapter } from '../../message/adapters/native' +import { createMessageEngine } from '../../message/core/engine' +import { lengthPlugin, skillPlugin, thinkingPlugin, toolPlugin } from '../../message/plugins' +import type { SkillDefinition } from '../types' +import type { CreateMessageEngineOptions, MessageRequestBody } from '../../message/types' +import { mockResponseProvider } from '../../message/test/mockResponseProvider' const silentDefaultPlugins = [thinkingPlugin({ disabled: true }), lengthPlugin({ disabled: true })] @@ -11,7 +12,7 @@ const createTestMessageEngine = (options: CreateMessageEngineOptions) => createMessageEngine(createNativeMessageAdapter(), options) describe('skillPlugin', () => { - it('injects active skill instructions and tools before request', async () => { + it('injects skill instructions and tools before request', async () => { const responseProvider = vi.fn(mockResponseProvider('ok')) const skillTool = { type: 'function', @@ -24,20 +25,18 @@ describe('skillPlugin', () => { }, }, } as const + const weatherSkill: SkillDefinition = { + name: 'weather', + description: 'Weather skill', + instructions: 'Use wttr.in for weather requests.', + tools: [skillTool], + } const engine = createTestMessageEngine({ plugins: [ ...silentDefaultPlugins, skillPlugin({ - skills: [ - { - name: 'weather', - description: 'Weather skill', - instructions: 'Use wttr.in for weather requests.', - tools: [skillTool], - }, - ], - getActiveSkills: () => ['weather'], + getSkills: () => [weatherSkill], }), toolPlugin({ getTools: async () => [], @@ -60,32 +59,30 @@ describe('skillPlugin', () => { it('resolves dynamic skill instructions and tools with runtime context', async () => { const responseProvider = vi.fn(mockResponseProvider('ok')) + const dynamicSkill: SkillDefinition = { + name: 'dynamic', + description: 'Dynamic skill', + instructions: ({ skill, skills }) => `${skill.name}:${skills.length}`, + tools: ({ skill }) => [ + { + type: 'function', + function: { + name: `${skill.name}_tool`, + description: 'Dynamic tool', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + ], + } const engine = createTestMessageEngine({ plugins: [ ...silentDefaultPlugins, skillPlugin({ - skills: [ - { - name: 'dynamic', - description: 'Dynamic skill', - instructions: ({ skill, activeSkills }) => `${skill.name}:${activeSkills.length}`, - tools: ({ skill }) => [ - { - type: 'function', - function: { - name: `${skill.name}_tool`, - description: 'Dynamic tool', - parameters: { - type: 'object', - properties: {}, - }, - }, - }, - ], - }, - ], - getActiveSkills: () => ['dynamic'], + getSkills: () => [dynamicSkill], }), toolPlugin({ getTools: async () => [], @@ -103,26 +100,25 @@ describe('skillPlugin', () => { }) it('throws duplicate tool names through toolPlugin when skill tools conflict', async () => { + const duplicateSkill: SkillDefinition = { + name: 'duplicate-skill', + description: 'Duplicate skill', + tools: [ + { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Duplicated skill tool', + }, + }, + ], + } + const engine = createTestMessageEngine({ plugins: [ ...silentDefaultPlugins, skillPlugin({ - skills: [ - { - name: 'duplicate-skill', - description: 'Duplicate skill', - tools: [ - { - type: 'function', - function: { - name: 'duplicate_tool', - description: 'Duplicated skill tool', - }, - }, - ], - }, - ], - getActiveSkills: () => ['duplicate-skill'], + getSkills: () => [duplicateSkill], }), toolPlugin({ getTools: async () => [ @@ -147,30 +143,28 @@ describe('skillPlugin', () => { ) }) - it('exposes built-in skill file runtime tools when active skills have files', async () => { + it('exposes built-in skill file runtime tools when skills have files', async () => { const responseProvider = vi.fn(mockResponseProvider('ok')) + const vueSkill: SkillDefinition = { + name: 'vue-best-practices', + description: 'Vue skill', + instructions: 'Follow Vue best practices.', + files: [ + { + id: 'references/reactivity.md', + path: 'references/reactivity.md', + kind: 'text', + content: '# Reactivity', + mimeType: 'text/markdown', + }, + ], + } const engine = createTestMessageEngine({ plugins: [ ...silentDefaultPlugins, skillPlugin({ - skills: [ - { - name: 'vue-best-practices', - description: 'Vue skill', - instructions: 'Follow Vue best practices.', - files: [ - { - id: 'references/reactivity.md', - path: 'references/reactivity.md', - kind: 'text', - content: '# Reactivity', - mimeType: 'text/markdown', - }, - ], - }, - ], - getActiveSkills: () => ['vue-best-practices'], + getSkills: () => [vueSkill], }), toolPlugin({ getTools: async () => [], @@ -200,21 +194,19 @@ describe('skillPlugin', () => { }) }) - it('does not expose built-in skill file runtime tools when active skills have no files', async () => { + it('does not expose built-in skill file runtime tools when skills have no files', async () => { const responseProvider = vi.fn(mockResponseProvider('ok')) + const plainSkill: SkillDefinition = { + name: 'plain', + description: 'Plain skill', + instructions: 'No files here.', + } const engine = createTestMessageEngine({ plugins: [ ...silentDefaultPlugins, skillPlugin({ - skills: [ - { - name: 'plain', - description: 'Plain skill', - instructions: 'No files here.', - }, - ], - getActiveSkills: () => ['plain'], + getSkills: () => [plainSkill], }), toolPlugin({ getTools: async () => [], @@ -292,6 +284,20 @@ describe('skillPlugin', () => { ], } }) + const vueSkill: SkillDefinition = { + name: 'vue-best-practices', + description: 'Vue skill', + instructions: 'Follow Vue best practices.', + files: [ + { + id: 'references/reactivity.md', + path: 'references/reactivity.md', + kind: 'text', + content: '# Reactivity', + mimeType: 'text/markdown', + }, + ], + } const engine = createTestMessageEngine({ plugins: [ @@ -303,23 +309,7 @@ describe('skillPlugin', () => { }, }), skillPlugin({ - skills: [ - { - name: 'vue-best-practices', - description: 'Vue skill', - instructions: 'Follow Vue best practices.', - files: [ - { - id: 'references/reactivity.md', - path: 'references/reactivity.md', - kind: 'text', - content: '# Reactivity', - mimeType: 'text/markdown', - }, - ], - }, - ], - getActiveSkills: () => ['vue-best-practices'], + getSkills: () => [vueSkill], }), ], responseProvider, diff --git a/packages/kit/src/skills/types.ts b/packages/kit/src/skills/types.ts new file mode 100644 index 000000000..c8cb7b688 --- /dev/null +++ b/packages/kit/src/skills/types.ts @@ -0,0 +1,103 @@ +import type { ChatCompletionFunctionTool } from 'openai/resources' +import type { MaybePromise } from '../types' +import type { BasePluginContext } from '../message/types' + +export type SkillFileKind = 'text' | 'binary' + +/** + * Skill 文件的公共数据模型。 + * + * 同时支持 browser (File API / showDirectoryPicker) 和 Node.js (fs) 两种环境。 + */ +export interface BaseSkillFile { + /** + * 基于 skill 根目录的相对路径。必须使用 / 分隔,不能以 / 开头,不能包含 ..。 + */ + path: string + /** + * MIME 类型。 + */ + mimeType?: string + /** + * 文件大小(字节)。 + */ + size?: number + /** + * 最后修改时间(时间戳)。 + */ + lastModified?: number + /** + * 文件元数据。可放来源、优先级、版本号等业务字段。 + */ + metadata?: Record +} + +export interface TextSkillFile extends BaseSkillFile { + kind: 'text' + content: string +} + +export interface BinarySkillFile extends BaseSkillFile { + kind: 'binary' + content: ArrayBuffer | Uint8Array +} + +export type SkillFile = TextSkillFile | BinarySkillFile + +export type SkillFileResource = SkillFile & { + /** + * 文件唯一标识。在同一个 skill 内应保持唯一,默认使用 path。 + */ + id: string +} + +/** + * 单个 Skill 的运行时上下文。 + * + * 用于动态生成 instructions、tools,或在回调中读取当前 turn 的 skill 列表。 + */ +export interface SkillRuntimeContext extends BasePluginContext { + /** + * 当前正在处理的 skill。 + */ + skill: SkillDefinition + /** + * 当前 turn 的全部 skills。 + */ + skills: SkillDefinition[] +} + +/** + * Skill 定义。 + * + * Skill 是一组提示词、工具和文件上下文的能力包。它最终通常会被编译为: + * - system/developer prompt + * - requestBody.tools + * - 可按需读取的文件上下文 + */ +export interface SkillDefinition { + /** + * Skill 唯一名称。用于去重、调试和持久化。 + */ + name: string + /** + * Skill 能力描述。可用于自动匹配,也可作为模型选择 skill 时的说明。 + */ + description: string + /** + * 注入给模型的 skill 指令。 + */ + instructions?: string | ((context: SkillRuntimeContext) => MaybePromise) + /** + * Skill 暴露的工具列表。 + */ + tools?: ChatCompletionFunctionTool[] | ((context: SkillRuntimeContext) => MaybePromise) + /** + * Skill 目录下除入口文件和工具配置外的文件数据。 + */ + files?: SkillFileResource[] + /** + * 业务侧自定义元数据。 + */ + metadata?: Record +} diff --git a/packages/kit/src/message/skills/utils.ts b/packages/kit/src/skills/utils.ts similarity index 100% rename from packages/kit/src/message/skills/utils.ts rename to packages/kit/src/skills/utils.ts From 68694315e00820cfd7eebdd8ab7d9aa5d0ea1b43 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Tue, 19 May 2026 14:24:16 +0800 Subject: [PATCH 3/9] feat: enhance skill management and file handling - Refactor skill compiler to improve instruction and tool compilation. - Introduce FsSkillFiles module for loading skill files from the filesystem. - Add SkillManager class for managing skill definitions and selection state. - Update SkillLoader to handle skill file loading and parsing with improved error handling. - Implement comprehensive tests for skill compilation, loading, and management functionalities. - Remove deprecated dynamic skill instruction and tool resolution tests. --- AGENTS.md | 39 +-- packages/kit/src/message/plugins/index.ts | 20 +- .../kit/src/message/plugins/skillPlugin.ts | 62 ++-- packages/kit/src/skills/README.md | 217 +++++++++++++ ...serSkillLoader.ts => browserSkillFiles.ts} | 8 +- packages/kit/src/skills/compiler.ts | 84 ++--- .../{fsSkillLoader.ts => fsSkillFiles.ts} | 13 +- packages/kit/src/skills/index.ts | 13 +- packages/kit/src/skills/manager.ts | 97 ++++++ packages/kit/src/skills/skillLoader.ts | 61 ++-- packages/kit/src/skills/test/compiler.test.ts | 290 ++++++++++++++++++ .../kit/src/skills/test/skillLoader.test.ts | 140 ++++++++- .../kit/src/skills/test/skillManager.test.ts | 132 ++++++++ .../kit/src/skills/test/skillPlugin.test.ts | 225 ++++---------- packages/kit/src/skills/types.ts | 57 +--- 15 files changed, 1060 insertions(+), 398 deletions(-) create mode 100644 packages/kit/src/skills/README.md rename packages/kit/src/skills/{browserSkillLoader.ts => browserSkillFiles.ts} (91%) rename packages/kit/src/skills/{fsSkillLoader.ts => fsSkillFiles.ts} (83%) create mode 100644 packages/kit/src/skills/manager.ts create mode 100644 packages/kit/src/skills/test/compiler.test.ts create mode 100644 packages/kit/src/skills/test/skillManager.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6d069f30e..7cbfa699d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ The goal is to make skills a standalone capability template, not a sub-feature o - Bridges `getSkills()` into message engine hooks. - `packages/kit/src/message/plugins` - Message plugins and runtime protocols. - - May re-export skill APIs for compatibility, but must not own skill core logic. + - Must not own or re-export skill core logic. ## Package Manager @@ -24,9 +24,12 @@ This repository uses pnpm for dependency and script management. Prefer `pnpm` co ## Skill Layers +- File Adapters + - Convert platform-specific file sources into `SkillFile[]`. + - Examples: `loadSkillFilesFromFs`, `loadSkillFilesFromFileList`, `loadSkillFilesFromDirectoryHandle`. - Loader - - Converts external sources into `SkillDefinition`. - - Examples: `SkillLoader`, `loadSkillFilesFromFs`, browser file loaders. + - Converts `SkillFile[]` into `SkillDefinition`. + - Lives in `packages/kit/src/skills/skillLoader.ts`. - Compiler - Converts `SkillDefinition[]` into request instructions, tool schemas, runtime tools, and compiler state. - Lives in `packages/kit/src/skills/compiler.ts`. @@ -34,8 +37,8 @@ This repository uses pnpm for dependency and script management. Prefer `pnpm` co - Connects skill compiler output to message engine lifecycle. - Lives in `packages/kit/src/message/plugins/skillPlugin.ts`. - Manager - - Not implemented yet. - - Future responsibility: add/remove/update/list/import/select skills. + - Lives in `packages/kit/src/skills/manager.ts`. + - Owns write/remove/list/import/select skills. - Must not compile request messages or tools. ## Hard Rules @@ -45,10 +48,11 @@ This repository uses pnpm for dependency and script management. Prefer `pnpm` co - `skillPlugin` receives the current turn's skills through `getSkills()`. - Do not use `activeSkills` naming in the skill plugin/compiler. The plugin receives skills that are already selected by outside logic. - Compiler may compile prompts/tools/runtime tools, but must not manage persistence, selection state, or storage. -- Loader may parse/import skill files, but must not own skill collections. -- Future manager may call loaders to import skills and may track selected skills, but must not compile request messages/tools. +- File adapters may read platform file sources, but must not parse skill semantics. +- Loader may parse/import skill files into a skill definition, but must not own skill collections. +- Manager may call loaders to import skills and may track selected skills, but must not compile request messages/tools. - Public skill APIs should be exported from `packages/kit/src/skills/index.ts`. -- Keep `message/plugins/index.ts` compatibility exports when useful, but prefer `src/skills` as the source of truth. +- `message/plugins/index.ts` must only export message plugin APIs; skill core APIs belong to `src/skills`. ## Current Public API Shape @@ -58,13 +62,6 @@ skillPlugin({ }) ``` -Skill runtime context uses: - -```ts -context.skill -context.skills -``` - Compiler state uses: ```ts @@ -78,10 +75,14 @@ state.runtimeTools - `packages/kit/src/skills/types.ts` - `packages/kit/src/skills/compiler.ts` - `packages/kit/src/skills/skillLoader.ts` -- `packages/kit/src/skills/fsSkillLoader.ts` -- `packages/kit/src/skills/browserSkillLoader.ts` +- `packages/kit/src/skills/manager.ts` +- `packages/kit/src/skills/fsSkillFiles.ts` +- `packages/kit/src/skills/browserSkillFiles.ts` - `packages/kit/src/skills/index.ts` +- `packages/kit/src/skills/README.md` +- `packages/kit/src/skills/test/compiler.test.ts` - `packages/kit/src/skills/test/skillLoader.test.ts` +- `packages/kit/src/skills/test/skillManager.test.ts` - `packages/kit/src/skills/test/skillPlugin.test.ts` - `packages/kit/src/skills/test/fixtures` - `packages/kit/src/message/plugins/skillPlugin.ts` @@ -98,7 +99,7 @@ pnpm build ## Near-Term Next Steps -- Add focused compiler unit tests. - Add `read_skill_file` size limits and truncation strategy. -- Design `skillManager` under `packages/kit/src/skills`. +- Decide where duplicate skill name diagnostics belong, preferably in manager or selection logic rather than compiler. +- Decide which Manager TODO items in `packages/kit/src/skills/README.md` should be promoted into implementation. - Keep manager boundaries separate from compiler boundaries. diff --git a/packages/kit/src/message/plugins/index.ts b/packages/kit/src/message/plugins/index.ts index dd41311f5..d13c83476 100644 --- a/packages/kit/src/message/plugins/index.ts +++ b/packages/kit/src/message/plugins/index.ts @@ -1,24 +1,6 @@ -export type { - BaseSkillFile, - BinarySkillFile, - SkillFile, - SkillFileKind, - SkillFileResource, - SkillDefinition, - SkillRuntimeContext, - TextSkillFile, -} from '../../skills/types' -export { - compileSkillInstructions, - compileSkillTools, - createSkillCompilerState, - createSkillFileRuntimeTools, - uniqueSkills, -} from '../../skills/compiler' -export type { SkillCompilerState } from '../../skills/compiler' export { lengthPlugin } from './lengthPlugin' export { skillPlugin } from './skillPlugin' -export type { SkillPluginState } from './skillPlugin' +export type { SkillPluginOptions, SkillPluginState } from './skillPlugin' export { thinkingPlugin } from './thinkingPlugin' export { toolPlugin } from './toolPlugin' export type { RuntimeTool, ToolCallContext, ToolProvider, ToolProviderItem } from './toolPlugin' diff --git a/packages/kit/src/message/plugins/skillPlugin.ts b/packages/kit/src/message/plugins/skillPlugin.ts index 140f989a4..2763bccf2 100644 --- a/packages/kit/src/message/plugins/skillPlugin.ts +++ b/packages/kit/src/message/plugins/skillPlugin.ts @@ -1,73 +1,45 @@ import type { MaybePromise } from '../../types' -import type { BasePluginContext, BeforeRequestContext, MessageEnginePlugin } from '../types' +import type { BasePluginContext, MessageEnginePlugin } from '../types' import { compileSkillInstructions, compileSkillTools, createSkillCompilerState } from '../../skills/compiler' import type { SkillCompilerState } from '../../skills/compiler' import type { SkillDefinition } from '../../skills/types' import type { ToolProvider } from './toolPlugin' /** - * TODO(skillPlugin): - * 1. 为 read_skill_file 增加长度限制和可配置截断策略,避免超长文件一次性进入上下文。 - * Add size limits and configurable truncation for read_skill_file to avoid injecting - * oversized files into context in one call. - * 2. 实现 skill files 消费策略:支持文件选择、去重、上下文格式化与长度控制。 - * Implement skill files consumption strategy: file selection, deduplication, - * context formatting, and length control. - * 3. 增加 Vue 层状态暴露封装:将当前 skills 同步给 UI 展示或调试面板。 - * Expose Vue-side state for current skills so UI/debug panels can display them. - * 4. 补充测试:覆盖 getSkills、customContext 写入、compileSkills 调用、 - * 重名 skill 去重以及和其他插件的 hook 顺序。 - * Add tests for getSkills, customContext state, compileSkills, - * duplicate skill deduplication, and plugin hook ordering. - */ - -/** - * 本轮 skill 转换状态。 + * Skill 插件的转换状态。 * - * 该对象会写入 customContext.__tiny_robot_skill,供后续插件或业务回调读取。 + * 该状态会写入 customContext.__tiny_robot_skill,供消息钩子和插件回调读取同一份编译结果。 */ export type SkillPluginState = SkillCompilerState /** - * skillPlugin 配置项。 + * 将已选择的 skills 转换为消息指令和工具的配置项。 */ export type SkillPluginOptions = MessageEnginePlugin & { /** - * 获取当前 turn 要转换的 skills。 + * 返回本次请求要使用的 skills。 * - * skillPlugin 不持有、不查询、不缓存可用 skill 列表。UI 选择、规则匹配、模型选择、后端策略 - * 或独立的 skills 管理工具都应在外部收敛为本轮要转换的 SkillDefinition 列表。 + * 插件只转换返回的 skills;选择、存储和集合管理由调用方负责。 */ getSkills?: (context: BasePluginContext) => MaybePromise /** - * Skills 获取并去重完成后触发。 - * - * 可用于记录日志、同步 UI 状态或调试本轮转换结果。 + * skills 解析并规整为编译状态后触发。 */ onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise - /** - * 请求前的 skill 编译钩子。 - * - * 当前插件暂不内置 prompt/tools 注入策略,业务侧可以先在这里实验编译逻辑。 - * 后续稳定后再沉淀为内置实现。 - */ - compileSkills?: (state: SkillPluginState, context: BeforeRequestContext) => MaybePromise } const skillPluginContextKey = '__tiny_robot_skill' -export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin => { - const { getSkills, onSkillsResolved, compileSkills, ...restOptions } = options - - const provideSkillTools = async (context: BasePluginContext) => { - const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined - return state ? compileSkillTools(state, context) : [] - } +export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin & ToolProvider => { + const { getSkills, onSkillsResolved, ...restOptions } = options return { name: 'skill', ...restOptions, - provideTools: provideSkillTools, + provideTools: async (context: BasePluginContext) => { + const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined + return state ? compileSkillTools(state) : [] + }, onTurnStart: async (context) => { const state = createSkillCompilerState((await getSkills?.(context)) ?? []) @@ -80,11 +52,13 @@ export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin => const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined if (state) { - await compileSkillInstructions(state, context) - await compileSkills?.(state, context) + const skillInstructions = await compileSkillInstructions(state.skills) + if (skillInstructions) { + context.requestBody.messages = [skillInstructions, ...context.requestBody.messages] + } } return restOptions.onBeforeRequest?.(context) }, - } as MessageEnginePlugin & ToolProvider + } satisfies MessageEnginePlugin & ToolProvider } diff --git a/packages/kit/src/skills/README.md b/packages/kit/src/skills/README.md new file mode 100644 index 000000000..ccfdc3e27 --- /dev/null +++ b/packages/kit/src/skills/README.md @@ -0,0 +1,217 @@ +# Skill Message Plugin Flow + +本文档说明 skill 如何通过 `skillPlugin` 接入 message 插件体系,以及关键数据在 loader、compiler、plugin hook 之间的流转方式。 + +## 核心边界 + +- `SkillDefinition` 是 skill 的能力模板,包含 `name`、`description`、`instructions`、`tools`、`files` 和 `metadata`。 +- `fsSkillFiles` 和 `browserSkillFiles` 是文件适配器,只负责把平台文件源转换为 `SkillFile[]`。 +- `SkillLoader` 负责把 `SkillFile[]` 解析为 `SkillDefinition`。 +- `skillPlugin` 不加载、不选择、不缓存、不管理 skills,只通过 `getSkills()` 接收本次请求要使用的 skills。 +- `compiler` 只负责把 `SkillDefinition[]` 转换为 message engine 可消费的 instructions、tools 和 compiler state。 +- `SkillManager` 只负责 skill 集合和选择状态,不编译 instructions 或 tools。 +- `message` 侧只通过插件 hook 消费 compiler 输出。 + +## 流程图 + +```mermaid +flowchart TD + A["外部逻辑
    UI / selector / manager / 调用方"] -->|"getSkills()"| B["skillPlugin.onTurnStart"] + B -->|"SkillDefinition[]"| C["createSkillCompilerState(skills)"] + C --> D["uniqueSkills(skills)
    按 name 去重,保留第一个"] + D --> E["createSkillFileRuntimeTools(skills)"] + E -->|"有 files 时生成"| F["runtimeTools
    list_skill_files
    read_skill_file"] + E -->|"无 files"| G["runtimeTools = undefined"] + F --> H["SkillCompilerState"] + G --> H + D --> H + H -->|"setCustomContext"| I["customContext.__tiny_robot_skill"] + I --> J["skillPlugin.provideTools"] + I --> K["skillPlugin.onBeforeRequest"] + J -->|"compileSkillTools(state)"| L["message tools"] + L --> L1["基础文件工具
    state.runtimeTools"] + L --> L2["业务 skill 工具
    skill.tools"] + K -->|"compileSkillInstructions(state.skills)"| M["system message"] + M -->|"prepend"| N["requestBody.messages"] +``` + +## 关键数据处理 + +### `getSkills()` + +`getSkills()` 由调用方提供,返回本次请求要使用的 `SkillDefinition[]`。返回值已经代表被选中的 skills,插件内部不保留 `activeSkills` 语义。 + +```ts +skillPlugin({ + getSkills: () => [skill], +}) +``` + +### `SkillCompilerState` + +`onTurnStart` 会把 `getSkills()` 的结果转换为 compiler state,并写入 `customContext.__tiny_robot_skill`。 + +```ts +type SkillCompilerState = { + skills: SkillDefinition[] + skillNames: string[] + runtimeTools?: RuntimeTool[] +} +``` + +- `skills`:去重后的 skill 列表。 +- `skillNames`:从 `skills` 提取的名称列表,便于展示、日志或调试。 +- `runtimeTools`:由 skill 文件资源生成的基础文件工具。 + +### Instructions + +`onBeforeRequest` 从 compiler state 中读取 `state.skills`,调用 `compileSkillInstructions(state.skills)`。 + +处理规则: + +- 只读取 `skill.instructions` 字符串。 +- 空字符串或只包含空白的 instructions 会被忽略。 +- 每个 skill 的 instructions 会按 `## skill.name` 分段。 +- 编译结果作为 system message 插入到 `requestBody.messages` 最前面。 + +### Tools + +`provideTools` 从 compiler state 中读取 state,调用 `compileSkillTools(state)`。 + +工具来源分为两类: + +- 基础文件工具:由 `createSkillFileRuntimeTools(skills)` 根据 `skill.files` 创建。 +- 业务 skill 工具:由 `skill.tools` 提供,是静态工具数组。 + +`compileSkillTools(state)` 会先返回基础文件工具,再返回业务 skill 工具。 + +### 基础文件工具 + +当任意 skill 带有 `files` 时,compiler 会创建两个基础 runtime tools: + +- `list_skill_files`:列出当前 skills 携带的文件资源。 +- `read_skill_file`:按 `skillName` 和相对路径读取文本文件内容。 + +二进制文件只返回文件摘要,不返回内容。 + +## Hook 对应关系 + +| Hook | 输入 | 处理 | 输出 | +| --- | --- | --- | --- | +| `onTurnStart` | `getSkills()` | 创建 `SkillCompilerState` 并写入 `customContext` | `customContext.__tiny_robot_skill` | +| `provideTools` | `SkillCompilerState` | 编译基础文件工具和业务工具 | `ToolProviderItem[]` | +| `onBeforeRequest` | `SkillCompilerState.skills` | 编译 skill instructions | prepend system message | + +## SkillManager + +`SkillManager` 是框架无关的 skill 集合管理工具。它可以被业务层、组件层 adapter 或测试代码复用,但不依赖 message engine。 + +基础能力: + +- `set(skill)`:写入 skill,同名时覆盖,不存在时新增。 +- `remove(name)`:删除 skill,并从选择状态中移除。 +- `get(name)` / `has(name)` / `list()`:查询 skill。 +- `select(names)` / `unselect(names)`:维护选择状态。 +- `getSelectedSkillNames()` / `getSelectedSkills()`:读取已选 skills。 +- `import(files, options)`:通过 `SkillLoader` 从 `SkillFile[]` 导入 skill。 + +`skillPlugin` 可以直接读取 manager 选择结果: + +```ts +const manager = new SkillManager() + +skillPlugin({ + getSkills: () => manager.getSelectedSkills(), +}) +``` + +## Auto Skill Selection + +auto skill selection 是一个独立的 selector 层能力,用于让模型根据用户问题从候选 skills 中选择本次请求要启用的 skills。它不属于 `skillPlugin` 的职责。 + +推荐链路: + +```txt +用户问题 + -> selector turn: 模型读取候选 skill descriptions,并调用 selectSkills + -> request-local selected skill names + -> execution turn: skillPlugin 读取已选 skills,编译 instructions/tools + -> 模型使用已启用 skills 回答 +``` + +职责边界: + +- `SkillManager` 管理全部可用 skills。 +- `SkillSelector` 根据用户问题和候选 skill descriptions 产出本次请求的 selected skill names。 +- `skillPlugin` 只读取 selected `SkillDefinition[]`,并编译 instructions 和 tools。 +- auto selection 的结果应写入请求级状态,例如 `customContext.__tiny_robot_selected_skills`,不能直接写入 manager 的长期选择状态。 + +selector 阶段只提供候选摘要,不提供完整 instructions: + +```txt +Available skills: +- weather: Get current weather information. +- vue-best-practices: Vue.js best practices workflow. +``` + +selector 工具可以设计为: + +```ts +selectSkills({ + skillNames: string[] +}) +``` + +工具 JSON schema 应使用候选 skill names 限制可选范围: + +```json +{ + "type": "object", + "properties": { + "skillNames": { + "type": "array", + "items": { + "type": "string", + "enum": ["weather", "vue-best-practices"] + } + } + }, + "required": ["skillNames"], + "additionalProperties": false +} +``` + +selector 工具返回值建议是结构化结果,便于调试和日志记录: + +```json +{ + "selectedSkillNames": ["vue-best-practices"] +} +``` + +execution 阶段再把 selected skill definitions 交给 `skillPlugin`: + +```ts +skillPlugin({ + getSkills: (context) => context.customContext.__tiny_robot_selected_skills ?? [], +}) +``` + +为了避免循环调用,selector 层应维护请求级状态,例如: + +```ts +selectionStatus: 'pending' | 'done' +``` + +- `pending` 阶段提供 `selectSkills` 工具。 +- `done` 阶段不再提供 selector 工具。 +- `selectSkills` 每个请求最多调用一次。 + +## Manager TODO + +- P1: 实现 auto skill selection。按本文档的独立 selector 层设计,让模型通过 `selectSkills` 工具选择请求级 skills,再交给 `skillPlugin` 编译。 +- P1: 增加重复 skill 名称的诊断结果,用于 UI 提示或导入报告。 +- P1: 设计持久化 adapter 协议,例如 localStorage、IndexedDB 或远程接口。 +- P2: 增加批量导入结果,支持部分成功、部分失败和 warnings 汇总。 +- P2: 增加 skill 启用状态、标签、来源、版本等管理字段的推荐 schema。 +- P3: 增加 manager 事件或订阅机制,供 UI adapter 做响应式同步。 diff --git a/packages/kit/src/skills/browserSkillLoader.ts b/packages/kit/src/skills/browserSkillFiles.ts similarity index 91% rename from packages/kit/src/skills/browserSkillLoader.ts rename to packages/kit/src/skills/browserSkillFiles.ts index c92ae778e..3aeb8c004 100644 --- a/packages/kit/src/skills/browserSkillLoader.ts +++ b/packages/kit/src/skills/browserSkillFiles.ts @@ -18,9 +18,7 @@ export type BrowserDirectoryHandle = { } /** - * 前端 FileList 适配器。 - * - * 支持 选出的文件列表。 + * Browser FileList 适配器,用于读取文件选择器选中的 skill 目录。 */ export const loadSkillFilesFromFileList = async (fileList: ArrayLike): Promise => { const files = Array.from({ length: fileList.length }, (_, index) => fileList[index]).filter( @@ -36,9 +34,7 @@ export const loadSkillFilesFromFileList = async (fileList: ArrayLike = [ }, ] -const hasSkillFiles = (skills: SkillDefinition[]) => skills.some((skill) => Boolean(skill.files?.length)) - const getSkillFileSummary = (skillName: string, file: SkillFileResource) => ({ skillName, id: file.id, @@ -108,8 +105,13 @@ const parseSkillToolArguments = (toolCall: Parameters[0] } } +/** + * 创建基础的 skill 文件工具,用于列出和读取 skill 携带的文件资源。 + */ export const createSkillFileRuntimeTools = (skills: SkillDefinition[]): RuntimeTool[] => { - if (!hasSkillFiles(skills)) { + const hasSkillFiles = skills.some((skill) => Boolean(skill.files?.length)) + + if (!hasSkillFiles) { return [] } @@ -183,68 +185,30 @@ export const createSkillCompilerState = (skills: SkillDefinition[]): SkillCompil } } -const resolveSkillInstructions = async (skill: SkillDefinition, context: SkillRuntimeContext) => { - if (!skill.instructions) { - return '' - } - - const instructions = typeof skill.instructions === 'function' ? await skill.instructions(context) : skill.instructions - return instructions.trim() -} - -const resolveSkillTools = async (skill: SkillDefinition, context: SkillRuntimeContext) => { - if (!skill.tools) { - return [] - } - - return typeof skill.tools === 'function' ? await skill.tools(context) : skill.tools -} - export const compileSkillInstructions = async ( - state: Pick, - context: BeforeRequestContext, -) => { + skills: SkillDefinition[], +): Promise => { const instructions: string[] = [] - for (const skill of state.skills) { - const runtimeContext: SkillRuntimeContext = { - ...context, - skill, - skills: state.skills, - } - - const instruction = await resolveSkillInstructions(skill, runtimeContext) + for (const skill of skills) { + const instruction = skill.instructions?.trim() if (instruction) { instructions.push(`## ${skill.name}\n\n${instruction}`) } } - if (instructions.length > 0) { - context.requestBody.messages = [ - { - role: 'system', - content: ['Apply these skill instructions when generating the response.', ...instructions].join('\n\n'), - }, - ...context.requestBody.messages, - ] + if (instructions.length === 0) { + return undefined } -} -export const compileSkillTools = async ( - state: Pick, - context: BasePluginContext, -): Promise => { - const skillTools: ChatCompletionFunctionTool[] = [] - - for (const skill of state.skills) { - const runtimeContext: SkillRuntimeContext = { - ...context, - skill, - skills: state.skills, - } - - skillTools.push(...(await resolveSkillTools(skill, runtimeContext))) + return { + role: 'system', + content: ['Apply these skill instructions when generating the response.', ...instructions].join('\n\n'), } +} + +export const compileSkillTools = (state: Pick): ToolProviderItem[] => { + const skillTools = state.skills.flatMap((skill) => skill.tools ?? []) return [...(state.runtimeTools ?? []), ...skillTools] } diff --git a/packages/kit/src/skills/fsSkillLoader.ts b/packages/kit/src/skills/fsSkillFiles.ts similarity index 83% rename from packages/kit/src/skills/fsSkillLoader.ts rename to packages/kit/src/skills/fsSkillFiles.ts index e8bc962da..b047060d8 100644 --- a/packages/kit/src/skills/fsSkillLoader.ts +++ b/packages/kit/src/skills/fsSkillFiles.ts @@ -3,22 +3,17 @@ import { join, relative } from 'node:path' import type { SkillFile } from './types' import { isTextSkillFilePath, normalizeSkillPath } from './utils' -export interface FsSkillFileLoaderOptions { +export interface FsSkillFilesOptions { /** - * 忽略的目录名。 + * 遍历时排除的目录名。 */ ignoredDirectories?: string[] } /** - * 后端/Node 侧目录适配器。 - * - * 只负责把本地目录读取为 SkillFile[],不解析 skill 语义。 + * Node.js 目录适配器,将本地 skill 目录读取为 SkillFile 记录。 */ -export const loadSkillFilesFromFs = async ( - root: string, - options: FsSkillFileLoaderOptions = {}, -): Promise => { +export const loadSkillFilesFromFs = async (root: string, options: FsSkillFilesOptions = {}): Promise => { const ignoredDirectories = new Set(options.ignoredDirectories ?? ['.git', 'node_modules']) const result: SkillFile[] = [] diff --git a/packages/kit/src/skills/index.ts b/packages/kit/src/skills/index.ts index 3d2bec90d..5e28c8011 100644 --- a/packages/kit/src/skills/index.ts +++ b/packages/kit/src/skills/index.ts @@ -1,3 +1,5 @@ +export { loadSkillFilesFromDirectoryHandle, loadSkillFilesFromFileList } from './browserSkillFiles' +export type { BrowserDirectoryHandle, BrowserFile, BrowserFileHandle } from './browserSkillFiles' export { compileSkillInstructions, compileSkillTools, @@ -6,12 +8,12 @@ export { uniqueSkills, } from './compiler' export type { SkillCompilerState } from './compiler' -export { loadSkillFilesFromDirectoryHandle, loadSkillFilesFromFileList } from './browserSkillLoader' -export type { BrowserDirectoryHandle, BrowserFile, BrowserFileHandle } from './browserSkillLoader' -export { loadSkillFilesFromFs } from './fsSkillLoader' -export type { FsSkillFileLoaderOptions } from './fsSkillLoader' +export { loadSkillFilesFromFs } from './fsSkillFiles' +export type { FsSkillFilesOptions } from './fsSkillFiles' +export { SkillManager } from './manager' +export type { SkillManagerOptions } from './manager' export { SkillLoader } from './skillLoader' -export type { LoadedSkill, SkillLoaderOptions, SkillLoaderWarning } from './skillLoader' +export type { SkillLoaderOptions, SkillLoaderResult } from './skillLoader' export type { BaseSkillFile, BinarySkillFile, @@ -19,7 +21,6 @@ export type { SkillFile, SkillFileKind, SkillFileResource, - SkillRuntimeContext, TextSkillFile, } from './types' export { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' diff --git a/packages/kit/src/skills/manager.ts b/packages/kit/src/skills/manager.ts new file mode 100644 index 000000000..08f6e7f49 --- /dev/null +++ b/packages/kit/src/skills/manager.ts @@ -0,0 +1,97 @@ +import type { SkillDefinition, SkillFile } from './types' +import { SkillLoader } from './skillLoader' +import type { SkillLoaderOptions, SkillLoaderResult } from './skillLoader' + +export type SkillManagerOptions = { + /** + * 初始化时写入 manager 的 skill 列表。 + */ + skills?: SkillDefinition[] + /** + * 初始化时选中的 skill 名称。 + */ + selectedSkillNames?: string[] +} + +/** + * 管理 skill 集合和选择状态。 + * + * manager 不编译 prompt 或 tools,也不接入 message 生命周期。 + */ +export class SkillManager { + private skills = new Map() + private selectedSkillNames = new Set() + + constructor(options: SkillManagerOptions = {}) { + for (const skill of options.skills ?? []) { + this.set(skill) + } + + this.select(options.selectedSkillNames ?? []) + } + + set(skill: SkillDefinition) { + this.skills.set(skill.name, skill) + return skill + } + + remove(name: string) { + const skill = this.get(name) + + this.skills.delete(name) + this.selectedSkillNames.delete(name) + + return skill + } + + clear() { + this.skills.clear() + this.selectedSkillNames.clear() + } + + get(name: string) { + return this.skills.get(name) + } + + has(name: string) { + return this.skills.has(name) + } + + list() { + return Array.from(this.skills.values()) + } + + select(names: string | string[]) { + for (const name of Array.isArray(names) ? names : [names]) { + if (!this.skills.has(name)) { + throw new Error(`Skill "${name}" does not exist.`) + } + + this.selectedSkillNames.add(name) + } + } + + unselect(names: string | string[]) { + for (const name of Array.isArray(names) ? names : [names]) { + this.selectedSkillNames.delete(name) + } + } + + getSelectedSkillNames() { + return Array.from(this.selectedSkillNames) + } + + getSelectedSkills() { + return this.getSelectedSkillNames().flatMap((name) => { + const skill = this.skills.get(name) + return skill ? [skill] : [] + }) + } + + import(files: SkillFile[], options: SkillLoaderOptions = {}): SkillLoaderResult { + const result = new SkillLoader(options).load(files) + + this.set(result.skill) + return result + } +} diff --git a/packages/kit/src/skills/skillLoader.ts b/packages/kit/src/skills/skillLoader.ts index edbd966d4..69f633205 100644 --- a/packages/kit/src/skills/skillLoader.ts +++ b/packages/kit/src/skills/skillLoader.ts @@ -3,48 +3,45 @@ import { parse as parseYaml } from 'yaml' import type { SkillDefinition, SkillFile, SkillFileResource } from './types' import { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' -export interface SkillLoaderWarning { +export interface SkillLoaderResult { /** - * 警告类型,便于 UI 或日志分类。 - */ - code: string - /** - * 人类可读的警告说明。 - */ - message: string - /** - * 关联文件路径。 - */ - path?: string -} - -export interface LoadedSkill { - /** - * 从文件列表解析出的 SkillDefinition。 + * 从源文件解析出的 skill 定义。 */ skill: SkillDefinition /** - * 加载过程中的非致命问题。 + * 非致命的加载警告。 */ - warnings: SkillLoaderWarning[] + warnings: Array<{ + /** + * 用于 UI 和日志分类的警告编码。 + */ + code: string + /** + * 人类可读的警告信息。 + */ + message: string + /** + * 关联的 skill 文件路径。 + */ + path?: string + }> } export interface SkillLoaderOptions { /** - * Skill 入口文件名。 + * skill 入口文件名。 */ entryFile?: string /** - * 严格模式。开启后,tools.json 等关键文件解析失败会直接抛错。 + * 启用后,警告会直接抛出为错误。 */ strict?: boolean } /** - * 将标准化后的 skill 文件列表解析为 SkillDefinition。 + * 将标准化后的 skill 文件转换为 SkillDefinition。 * - * 该类不关心文件来自前端 FileSystemHandle、后端 fs、zip 还是远程接口; - * 调用方只需要先把文件来源适配为 SkillFile[]。 + * 文件来源适配器负责提供 SkillFile[];该 loader 负责解析入口文件、工具声明和资源文件。 */ export class SkillLoader { private entryFile: string @@ -55,8 +52,8 @@ export class SkillLoader { this.strict = options.strict ?? false } - load(files: SkillFile[]): LoadedSkill { - const warnings: SkillLoaderWarning[] = [] + load(files: SkillFile[]): SkillLoaderResult { + const warnings: SkillLoaderResult['warnings'] = [] const normalizedFiles = this.normalizeFiles(files, warnings) const entryFile = normalizedFiles.find((file) => file.path === this.entryFile) @@ -69,6 +66,12 @@ export class SkillLoader { } const { frontmatter, body } = parseMarkdownFrontmatter(entryFile.content) + const instructions = body.trim() + + if (!instructions) { + throw new Error(`Skill entry file "${this.entryFile}" must contain instructions.`) + } + const frontmatterMetadata = getRecord(frontmatter.metadata) const skillFiles: SkillFileResource[] = [] const tools: ChatCompletionFunctionTool[] = [] @@ -118,7 +121,7 @@ export class SkillLoader { skill: { name: getString(frontmatter.name) || getFallbackSkillName(this.entryFile), description: getString(frontmatter.description) || '', - instructions: body.trim(), + instructions, tools: tools.length ? tools : undefined, files: skillFiles.length ? skillFiles : undefined, metadata: { @@ -131,7 +134,7 @@ export class SkillLoader { } } - private normalizeFiles(files: SkillFile[], warnings: SkillLoaderWarning[]) { + private normalizeFiles(files: SkillFile[], warnings: SkillLoaderResult['warnings']) { const result: SkillFile[] = [] const seenPaths = new Set() @@ -163,7 +166,7 @@ export class SkillLoader { return result.sort((a, b) => a.path.localeCompare(b.path)) } - private handleWarning(warnings: SkillLoaderWarning[], warning: SkillLoaderWarning) { + private handleWarning(warnings: SkillLoaderResult['warnings'], warning: SkillLoaderResult['warnings'][number]) { if (this.strict) { throw new Error(warning.path ? `${warning.path}: ${warning.message}` : warning.message) } diff --git a/packages/kit/src/skills/test/compiler.test.ts b/packages/kit/src/skills/test/compiler.test.ts new file mode 100644 index 000000000..5a99a7acb --- /dev/null +++ b/packages/kit/src/skills/test/compiler.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import { + compileSkillInstructions, + compileSkillTools, + createSkillCompilerState, + createSkillFileRuntimeTools, + uniqueSkills, +} from '../compiler' + +const tool = (name: string) => + ({ + type: 'function', + function: { + name, + description: `${name} tool`, + parameters: { + type: 'object', + properties: {}, + }, + }, + }) as const + +describe('skill compiler', () => { + it('deduplicates skills by first matching name', () => { + const first = { name: 'weather', description: 'first', instructions: 'first instructions' } + const second = { name: 'weather', description: 'second', instructions: 'second instructions' } + const other = { name: 'vue', description: 'other', instructions: 'other instructions' } + + expect(uniqueSkills([first, second, other])).toEqual([first, other]) + }) + + it('creates compiler state with skill names and file runtime tools', () => { + const state = createSkillCompilerState([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + files: [ + { + id: 'guide.md', + path: 'guide.md', + kind: 'text', + content: '# Guide', + }, + ], + }, + { + name: 'plain', + description: 'Plain skill', + instructions: 'Use plain skill.', + }, + ]) + + expect(state.skills.map((skill) => skill.name)).toEqual(['docs', 'plain']) + expect(state.skillNames).toEqual(['docs', 'plain']) + expect(state.runtimeTools?.map((runtimeTool) => runtimeTool.tool.function.name)).toEqual([ + 'list_skill_files', + 'read_skill_file', + ]) + }) + + it('returns no runtime file tools when skills have no files', () => { + expect( + createSkillFileRuntimeTools([{ name: 'plain', description: 'Plain skill', instructions: 'Use plain skill.' }]), + ).toEqual([]) + }) + + it('compiles instructions into a system message', async () => { + const message = await compileSkillInstructions([ + { + name: 'weather', + description: 'Weather skill', + instructions: 'Use wttr.in.', + }, + { + name: 'vue', + description: 'Vue skill', + instructions: 'Use Vue best practices.', + }, + { + name: 'empty', + description: 'Empty skill', + instructions: ' ', + }, + ]) + + expect(message).toMatchObject({ role: 'system' }) + expect(message?.content).toContain('Apply these skill instructions') + expect(message?.content).toContain('## weather\n\nUse wttr.in.') + expect(message?.content).toContain('## vue\n\nUse Vue best practices.') + expect(message?.content).not.toContain('## empty') + }) + + it('does not compile an instruction message when no skill has instructions', async () => { + await expect( + compileSkillInstructions([{ name: 'plain', description: 'Plain skill', instructions: ' ' }]), + ).resolves.toBeUndefined() + }) + + it('compiles skill tools after runtime tools', () => { + const runtimeTools = createSkillFileRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + files: [ + { + id: 'guide.md', + path: 'guide.md', + kind: 'text', + content: '# Guide', + }, + ], + }, + ]) + + const compiledTools = compileSkillTools({ + runtimeTools, + skills: [ + { + name: 'static', + description: 'Static skill', + instructions: 'Use static skill.', + tools: [tool('static_tool')], + }, + { + name: 'extra', + description: 'Extra skill', + instructions: 'Use extra skill.', + tools: [tool('extra_tool')], + }, + ], + }) + + expect(compiledTools.map((toolItem) => ('tool' in toolItem ? toolItem.tool : toolItem).function.name)).toEqual([ + 'list_skill_files', + 'read_skill_file', + 'static_tool', + 'extra_tool', + ]) + }) + + it('lists and reads files through built-in runtime tools', () => { + const [listFiles, readFile] = createSkillFileRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + files: [ + { + id: 'guide.md', + path: 'guide.md', + kind: 'text', + content: '# Guide', + mimeType: 'text/markdown', + }, + { + id: 'icon.png', + path: 'icon.png', + kind: 'binary', + content: new Uint8Array([1, 2, 3]), + }, + ], + }, + ]) + + expect(listFiles.handler(createToolCall('list_skill_files', {}), {} as never)).toMatchObject({ + files: [ + { + skillName: 'docs', + path: 'guide.md', + kind: 'text', + }, + { + skillName: 'docs', + path: 'icon.png', + kind: 'binary', + }, + ], + }) + + expect( + readFile.handler(createToolCall('read_skill_file', { skillName: 'docs', path: 'guide.md' }), {} as never), + ).toMatchObject({ + file: { + skillName: 'docs', + path: 'guide.md', + kind: 'text', + }, + content: '# Guide', + }) + + expect( + readFile.handler(createToolCall('read_skill_file', { skillName: 'docs', path: 'icon.png' }), {} as never), + ).toMatchObject({ + error: 'binary_file_not_readable', + file: { + skillName: 'docs', + path: 'icon.png', + kind: 'binary', + }, + }) + }) + + it('filters listed files by skill name', () => { + const [listFiles] = createSkillFileRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + files: [ + { + id: 'guide.md', + path: 'guide.md', + kind: 'text', + content: '# Guide', + }, + ], + }, + { + name: 'vue', + description: 'Vue skill', + instructions: 'Use Vue.', + files: [ + { + id: 'sfc.md', + path: 'sfc.md', + kind: 'text', + content: '# SFC', + }, + ], + }, + ]) + + expect(listFiles.handler(createToolCall('list_skill_files', { skillName: 'vue' }), {} as never)).toMatchObject({ + files: [ + { + skillName: 'vue', + path: 'sfc.md', + }, + ], + }) + }) + + it('returns stable errors when reading skill files with invalid arguments', () => { + const [, readFile] = createSkillFileRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + files: [ + { + id: 'guide.md', + path: 'guide.md', + kind: 'text', + content: '# Guide', + }, + ], + }, + ]) + + expect(readFile.handler(createToolCallWithArguments('read_skill_file', '{'), {} as never)).toEqual({ + error: 'skill_not_found', + }) + expect(readFile.handler(createToolCall('read_skill_file', { skillName: 'docs' }), {} as never)).toEqual({ + error: 'file_path_required', + skillName: 'docs', + }) + expect( + readFile.handler(createToolCall('read_skill_file', { skillName: 'docs', path: 'missing.md' }), {} as never), + ).toEqual({ + error: 'file_not_found', + skillName: 'docs', + path: 'missing.md', + }) + }) +}) + +const createToolCall = (name: string, args: Record) => ({ + ...createToolCallWithArguments(name, JSON.stringify(args)), +}) + +const createToolCallWithArguments = (name: string, args: string) => ({ + id: `call_${name}`, + type: 'function' as const, + function: { + name, + arguments: args, + }, +}) diff --git a/packages/kit/src/skills/test/skillLoader.test.ts b/packages/kit/src/skills/test/skillLoader.test.ts index 8ae32a8e1..b6efb84d7 100644 --- a/packages/kit/src/skills/test/skillLoader.test.ts +++ b/packages/kit/src/skills/test/skillLoader.test.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { loadSkillFilesFromFs } from '../fsSkillLoader' +import { loadSkillFilesFromFs } from '../fsSkillFiles' import { SkillLoader } from '../skillLoader' describe('SkillLoader', () => { @@ -86,4 +86,142 @@ describe('SkillLoader', () => { ]) expect(loadedSkill.warnings).toEqual([]) }) + + it('throws when the entry file is missing', () => { + expect(() => new SkillLoader().load([])).toThrow('Skill entry file "SKILL.md" is missing.') + }) + + it('throws when the entry file is binary', () => { + expect(() => + new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'binary', + content: new Uint8Array([1, 2, 3]), + }, + ]), + ).toThrow('Skill entry file "SKILL.md" must be a text file.') + }) + + it('throws when the entry file has no instructions', () => { + expect(() => + new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: empty-skill', 'description: Empty skill', '---', ''].join('\n'), + }, + ]), + ).toThrow('Skill entry file "SKILL.md" must contain instructions.') + }) + + it('reports duplicate and unsupported file warnings', () => { + const loadedSkill = new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: warning-skill', 'description: Warning skill', '---', '', '# Warning'].join('\n'), + }, + { + path: 'notes.md', + kind: 'text', + content: 'first', + }, + { + path: 'notes.md', + kind: 'text', + content: 'second', + }, + { + path: 'script.ts', + kind: 'text', + content: 'export {}', + }, + ]) + + expect(loadedSkill.warnings).toEqual([ + { + code: 'duplicate-path', + message: 'Duplicate skill file path: notes.md', + path: 'notes.md', + }, + { + code: 'unsupported-text-file-ignored', + message: 'Only markdown, text, and json files are converted to text skill files.', + path: 'script.ts', + }, + ]) + expect(loadedSkill.skill.files?.map((file) => file.path)).toEqual(['notes.md']) + }) + + it('throws warnings as errors in strict mode', () => { + expect(() => + new SkillLoader({ strict: true }).load([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: strict-skill', 'description: Strict skill', '---', '', '# Strict'].join('\n'), + }, + { + path: 'notes.md', + kind: 'text', + content: 'first', + }, + { + path: 'notes.md', + kind: 'text', + content: 'second', + }, + ]), + ).toThrow('notes.md: Duplicate skill file path: notes.md') + }) + + it('parses valid tools and reports invalid tools', () => { + const loadedSkill = new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: tool-skill', 'description: Tool skill', '---', '', '# Tool'].join('\n'), + }, + { + path: 'tools.json', + kind: 'text', + content: JSON.stringify({ + type: 'function', + function: { + name: 'run_tool', + description: 'Run tool', + parameters: { + type: 'object', + properties: {}, + }, + }, + }), + }, + ]) + + expect(loadedSkill.skill.tools?.map((tool) => tool.function.name)).toEqual(['run_tool']) + expect(loadedSkill.warnings).toEqual([]) + + const invalidSkill = new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: invalid-tool-skill', 'description: Invalid tool skill', '---', '', '# Tool'].join('\n'), + }, + { + path: 'tools.json', + kind: 'text', + content: JSON.stringify({ type: 'invalid' }), + }, + ]) + + expect(invalidSkill.skill.tools).toBeUndefined() + expect(invalidSkill.warnings).toMatchObject([ + { + code: 'tools-parse-failed', + path: 'tools.json', + }, + ]) + }) }) diff --git a/packages/kit/src/skills/test/skillManager.test.ts b/packages/kit/src/skills/test/skillManager.test.ts new file mode 100644 index 000000000..0e54e56af --- /dev/null +++ b/packages/kit/src/skills/test/skillManager.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { SkillManager } from '../manager' + +const skill = (name: string, description = `${name} skill`) => ({ + name, + description, + instructions: `${name} instructions`, +}) + +describe('SkillManager', () => { + it('sets, lists, and removes skills', () => { + const manager = new SkillManager() + + manager.set(skill('weather')) + expect(manager.has('weather')).toBe(true) + expect(manager.get('weather')?.description).toBe('weather skill') + expect(manager.list().map((item) => item.name)).toEqual(['weather']) + + manager.set(skill('weather', 'Updated weather skill')) + expect(manager.get('weather')).toMatchObject({ + name: 'weather', + description: 'Updated weather skill', + }) + + expect(manager.remove('weather')?.name).toBe('weather') + expect(manager.list()).toEqual([]) + }) + + it('replaces existing skills with the same name', () => { + const manager = new SkillManager({ skills: [skill('weather')] }) + + manager.set(skill('weather', 'Replacement weather skill')) + + expect(manager.get('weather')?.description).toBe('Replacement weather skill') + }) + + it('manages selected skills in selection order', () => { + const weather = skill('weather') + const vue = skill('vue') + const manager = new SkillManager({ + skills: [weather, vue], + selectedSkillNames: ['vue'], + }) + + manager.select('weather') + expect(manager.getSelectedSkillNames()).toEqual(['vue', 'weather']) + expect(manager.getSelectedSkills()).toEqual([vue, weather]) + + manager.unselect('vue') + expect(manager.getSelectedSkillNames()).toEqual(['weather']) + }) + + it('throws when selecting missing skills', () => { + const manager = new SkillManager({ skills: [skill('weather')] }) + + expect(() => manager.select('missing')).toThrow('Skill "missing" does not exist.') + }) + + it('throws when initialized with missing selected skills', () => { + expect(() => new SkillManager({ selectedSkillNames: ['missing'] })).toThrow('Skill "missing" does not exist.') + }) + + it('removes deleted skills from the selection', () => { + const manager = new SkillManager({ + skills: [skill('weather')], + selectedSkillNames: ['weather'], + }) + + manager.remove('weather') + + expect(manager.getSelectedSkillNames()).toEqual([]) + expect(manager.getSelectedSkills()).toEqual([]) + }) + + it('clears skills and selected skills', () => { + const manager = new SkillManager({ + skills: [skill('weather')], + selectedSkillNames: ['weather'], + }) + + manager.clear() + + expect(manager.list()).toEqual([]) + expect(manager.getSelectedSkillNames()).toEqual([]) + }) + + it('imports skills through SkillLoader', () => { + const manager = new SkillManager() + const result = manager.import([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: docs', 'description: Docs skill', '---', '', '# Docs'].join('\n'), + }, + ]) + + expect(result.skill.name).toBe('docs') + expect(manager.get('docs')).toBe(result.skill) + }) + + it('replaces imported skills with the same name', () => { + const manager = new SkillManager({ skills: [skill('docs')] }) + + manager.import([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: docs', 'description: Imported docs skill', '---', '', '# Docs'].join('\n'), + }, + ]) + + expect(manager.get('docs')?.description).toBe('Imported docs skill') + }) + + it('passes loader options when importing skills', () => { + const manager = new SkillManager() + + const result = manager.import( + [ + { + path: 'README.md', + kind: 'text', + content: ['---', 'name: custom-entry', 'description: Custom entry skill', '---', '', '# Custom'].join('\n'), + }, + ], + { entryFile: 'README.md' }, + ) + + expect(result.skill.name).toBe('custom-entry') + expect(manager.get('custom-entry')).toBe(result.skill) + }) +}) diff --git a/packages/kit/src/skills/test/skillPlugin.test.ts b/packages/kit/src/skills/test/skillPlugin.test.ts index 07e91ec79..564cffce6 100644 --- a/packages/kit/src/skills/test/skillPlugin.test.ts +++ b/packages/kit/src/skills/test/skillPlugin.test.ts @@ -57,171 +57,6 @@ describe('skillPlugin', () => { expect(requestBody.tools).toEqual([skillTool]) }) - it('resolves dynamic skill instructions and tools with runtime context', async () => { - const responseProvider = vi.fn(mockResponseProvider('ok')) - const dynamicSkill: SkillDefinition = { - name: 'dynamic', - description: 'Dynamic skill', - instructions: ({ skill, skills }) => `${skill.name}:${skills.length}`, - tools: ({ skill }) => [ - { - type: 'function', - function: { - name: `${skill.name}_tool`, - description: 'Dynamic tool', - parameters: { - type: 'object', - properties: {}, - }, - }, - }, - ], - } - - const engine = createTestMessageEngine({ - plugins: [ - ...silentDefaultPlugins, - skillPlugin({ - getSkills: () => [dynamicSkill], - }), - toolPlugin({ - getTools: async () => [], - callTool: async () => 'fallback', - }), - ], - responseProvider, - }) - - await engine.sendMessage('run dynamic skill') - - const requestBody = responseProvider.mock.calls[0]?.[0] - expect(requestBody.messages[0].content).toContain('dynamic:1') - expect(requestBody.tools![0].function.name).toBe('dynamic_tool') - }) - - it('throws duplicate tool names through toolPlugin when skill tools conflict', async () => { - const duplicateSkill: SkillDefinition = { - name: 'duplicate-skill', - description: 'Duplicate skill', - tools: [ - { - type: 'function', - function: { - name: 'duplicate_tool', - description: 'Duplicated skill tool', - }, - }, - ], - } - - const engine = createTestMessageEngine({ - plugins: [ - ...silentDefaultPlugins, - skillPlugin({ - getSkills: () => [duplicateSkill], - }), - toolPlugin({ - getTools: async () => [ - { - type: 'function', - function: { - name: 'duplicate_tool', - description: 'Duplicated user tool', - }, - }, - ], - callTool: async () => 'fallback', - }), - ], - responseProvider: async () => { - throw new Error('responseProvider should not be called') - }, - }) - - await expect(engine.sendMessage('trigger duplicate tool')).rejects.toThrow( - 'Duplicate tool name "duplicate_tool" detected.', - ) - }) - - it('exposes built-in skill file runtime tools when skills have files', async () => { - const responseProvider = vi.fn(mockResponseProvider('ok')) - const vueSkill: SkillDefinition = { - name: 'vue-best-practices', - description: 'Vue skill', - instructions: 'Follow Vue best practices.', - files: [ - { - id: 'references/reactivity.md', - path: 'references/reactivity.md', - kind: 'text', - content: '# Reactivity', - mimeType: 'text/markdown', - }, - ], - } - - const engine = createTestMessageEngine({ - plugins: [ - ...silentDefaultPlugins, - skillPlugin({ - getSkills: () => [vueSkill], - }), - toolPlugin({ - getTools: async () => [], - callTool: async () => 'fallback', - }), - ], - responseProvider, - }) - - await engine.sendMessage('review this Vue component') - - const requestBody = responseProvider.mock.calls[0]?.[0] - expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['list_skill_files', 'read_skill_file']) - expect(requestBody.tools?.[0].function.parameters).toMatchObject({ - type: 'object', - properties: { - skillName: expect.objectContaining({ type: 'string' }), - }, - }) - expect(requestBody.tools?.[1].function.parameters).toMatchObject({ - type: 'object', - required: ['skillName', 'path'], - properties: { - skillName: expect.objectContaining({ type: 'string' }), - path: expect.objectContaining({ type: 'string' }), - }, - }) - }) - - it('does not expose built-in skill file runtime tools when skills have no files', async () => { - const responseProvider = vi.fn(mockResponseProvider('ok')) - const plainSkill: SkillDefinition = { - name: 'plain', - description: 'Plain skill', - instructions: 'No files here.', - } - - const engine = createTestMessageEngine({ - plugins: [ - ...silentDefaultPlugins, - skillPlugin({ - getSkills: () => [plainSkill], - }), - toolPlugin({ - getTools: async () => [], - callTool: async () => 'fallback', - }), - ], - responseProvider, - }) - - await engine.sendMessage('run plain skill') - - const requestBody = responseProvider.mock.calls[0]?.[0] - expect(requestBody.tools).toBeUndefined() - }) - it('executes built-in skill file runtime tools from turn state', async () => { const responseProvider = vi.fn(async (requestBody: MessageRequestBody) => { const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') @@ -323,4 +158,64 @@ describe('skillPlugin', () => { content: 'done', }) }) + + it('does not inject instructions or tools when getSkills returns undefined', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + getSkills: () => undefined, + }), + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + responseProvider, + }) + + await engine.sendMessage('hello') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.messages[0]).toMatchObject({ role: 'user', content: 'hello' }) + expect(requestBody.tools).toBeUndefined() + }) + + it('resolves compiler state before running custom turn hooks', async () => { + const resolvedState = vi.fn() + const turnStart = vi.fn() + const weatherSkill: SkillDefinition = { + name: 'weather', + description: 'Weather skill', + instructions: 'Use wttr.in.', + } + const responseProvider = vi.fn(mockResponseProvider('ok')) + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + getSkills: () => [weatherSkill], + onSkillsResolved: (state) => { + resolvedState(state.skillNames) + }, + onTurnStart: (context) => { + turnStart(context.customContext.__tiny_robot_skill) + }, + }), + ], + responseProvider, + }) + + await engine.sendMessage('weather') + + expect(resolvedState).toHaveBeenCalledWith(['weather']) + expect(turnStart).toHaveBeenCalledWith( + expect.objectContaining({ + skills: [weatherSkill], + skillNames: ['weather'], + }), + ) + expect(resolvedState.mock.invocationCallOrder[0]).toBeLessThan(turnStart.mock.invocationCallOrder[0]) + }) }) diff --git a/packages/kit/src/skills/types.ts b/packages/kit/src/skills/types.ts index c8cb7b688..b895cbc38 100644 --- a/packages/kit/src/skills/types.ts +++ b/packages/kit/src/skills/types.ts @@ -1,33 +1,29 @@ import type { ChatCompletionFunctionTool } from 'openai/resources' -import type { MaybePromise } from '../types' -import type { BasePluginContext } from '../message/types' export type SkillFileKind = 'text' | 'binary' /** - * Skill 文件的公共数据模型。 - * - * 同时支持 browser (File API / showDirectoryPicker) 和 Node.js (fs) 两种环境。 + * skill 文件的公共元数据。 */ export interface BaseSkillFile { /** - * 基于 skill 根目录的相对路径。必须使用 / 分隔,不能以 / 开头,不能包含 ..。 + * 基于 skill 根目录的相对路径,使用 "/" 分隔,不能以 "/" 开头,也不能包含 ".." 片段。 */ path: string /** - * MIME 类型。 + * 文件来源提供的 MIME 类型。 */ mimeType?: string /** - * 文件大小(字节)。 + * 文件大小,单位为字节。 */ size?: number /** - * 最后修改时间(时间戳)。 + * 文件来源提供的最后修改时间戳。 */ lastModified?: number /** - * 文件元数据。可放来源、优先级、版本号等业务字段。 + * 应用侧自定义的来源元数据。 */ metadata?: Record } @@ -46,58 +42,39 @@ export type SkillFile = TextSkillFile | BinarySkillFile export type SkillFileResource = SkillFile & { /** - * 文件唯一标识。在同一个 skill 内应保持唯一,默认使用 path。 + * 所属 skill 内唯一的文件标识。 */ id: string } /** - * 单个 Skill 的运行时上下文。 - * - * 用于动态生成 instructions、tools,或在回调中读取当前 turn 的 skill 列表。 - */ -export interface SkillRuntimeContext extends BasePluginContext { - /** - * 当前正在处理的 skill。 - */ - skill: SkillDefinition - /** - * 当前 turn 的全部 skills。 - */ - skills: SkillDefinition[] -} - -/** - * Skill 定义。 + * skill 能力模板。 * - * Skill 是一组提示词、工具和文件上下文的能力包。它最终通常会被编译为: - * - system/developer prompt - * - requestBody.tools - * - 可按需读取的文件上下文 + * skill 可以提供指令、工具和文件资源,并被编译到消息请求中。 */ export interface SkillDefinition { /** - * Skill 唯一名称。用于去重、调试和持久化。 + * 唯一的 skill 名称。 */ name: string /** - * Skill 能力描述。可用于自动匹配,也可作为模型选择 skill 时的说明。 + * 用于发现、匹配或展示的能力描述。 */ description: string /** - * 注入给模型的 skill 指令。 + * 注入模型请求的指令。 */ - instructions?: string | ((context: SkillRuntimeContext) => MaybePromise) + instructions: string /** - * Skill 暴露的工具列表。 + * skill 暴露的函数工具。 */ - tools?: ChatCompletionFunctionTool[] | ((context: SkillRuntimeContext) => MaybePromise) + tools?: ChatCompletionFunctionTool[] /** - * Skill 目录下除入口文件和工具配置外的文件数据。 + * 可供 skill 文件运行时工具读取的文件。 */ files?: SkillFileResource[] /** - * 业务侧自定义元数据。 + * 应用侧自定义元数据。 */ metadata?: Record } From eff828cff68fc13a6cd2a9a924d3e5b967292806 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Tue, 19 May 2026 19:44:27 +0800 Subject: [PATCH 4/9] feat: enhance skill plugin architecture and add node support - Introduced a new entry point for Node.js in package.json to support file system operations. - Updated build and development scripts to include the new node.ts file. - Refactored skillPlugin to manage skill states and runtime tools more effectively. - Removed deprecated skill tools handling from the SkillLoader and compiler. - Improved documentation to clarify the skill toolchain architecture and responsibilities. - Added Vue integration for skillPlugin to support reactive skill management. - Updated tests to reflect changes in skill handling and ensure proper functionality. --- AGENTS.md | 13 +- docs/.vitepress/themeConfig.ts | 1 + docs/demos/tools/skill/SkillInspector.css | 278 ++++++++++++++++ docs/demos/tools/skill/SkillInspector.vue | 121 +++++++ docs/demos/tools/skill/VueSkillPlugin.css | 137 ++++++++ docs/demos/tools/skill/VueSkillPlugin.vue | 183 ++++++++++ docs/demos/tools/skill/exampleSkillFiles.ts | 22 ++ docs/demos/tools/skill/useSkillInspector.ts | 129 ++++++++ docs/src/tools/skill.md | 312 ++++++++++++++++++ packages/kit/package.json | 9 +- packages/kit/src/index.ts | 1 - .../kit/src/message/plugins/skillPlugin.ts | 22 +- packages/kit/src/node.ts | 2 + packages/kit/src/skills/README.md | 241 ++++++++------ packages/kit/src/skills/compiler.ts | 50 +-- packages/kit/src/skills/index.ts | 11 +- packages/kit/src/skills/skillLoader.ts | 49 +-- packages/kit/src/skills/test/compiler.test.ts | 80 +---- .../kit/src/skills/test/skillLoader.test.ts | 27 +- .../kit/src/skills/test/skillPlugin.test.ts | 16 +- packages/kit/src/skills/types.ts | 6 - packages/kit/src/vue/message/plugins/index.ts | 1 + .../src/vue/message/plugins/skillPlugin.ts | 51 +++ .../kit/src/vue/message/useMessage.test.ts | 44 ++- 24 files changed, 1459 insertions(+), 347 deletions(-) create mode 100644 docs/demos/tools/skill/SkillInspector.css create mode 100644 docs/demos/tools/skill/SkillInspector.vue create mode 100644 docs/demos/tools/skill/VueSkillPlugin.css create mode 100644 docs/demos/tools/skill/VueSkillPlugin.vue create mode 100644 docs/demos/tools/skill/exampleSkillFiles.ts create mode 100644 docs/demos/tools/skill/useSkillInspector.ts create mode 100644 docs/src/tools/skill.md create mode 100644 packages/kit/src/node.ts create mode 100644 packages/kit/src/vue/message/plugins/skillPlugin.ts diff --git a/AGENTS.md b/AGENTS.md index 7cbfa699d..60c4b3e87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,15 @@ The active development track is the skill toolchain in `packages/kit`. -The goal is to make skills a standalone capability template, not a sub-feature of `message`. A skill can be loaded from files, managed later by a manager, and compiled into prompt instructions plus tools for the message engine. +The goal is to make skills a standalone capability template, not a sub-feature of `message`. A skill can be loaded from files, managed by a manager, and compiled into prompt instructions plus built-in file tools for the message engine. ## Current Architecture - `packages/kit/src/skills` - Core skill toolchain modules. - Owns skill loading, skill types, compiler helpers, fixtures, and skill tests. + - Browser-safe skill APIs are exported from `@opentiny/tiny-robot-kit/core`. + - Node-only file adapters are exported from `@opentiny/tiny-robot-kit/node`. - `packages/kit/src/message/plugins/skillPlugin.ts` - Message runtime adapter only. - Bridges `getSkills()` into message engine hooks. @@ -31,7 +33,7 @@ This repository uses pnpm for dependency and script management. Prefer `pnpm` co - Converts `SkillFile[]` into `SkillDefinition`. - Lives in `packages/kit/src/skills/skillLoader.ts`. - Compiler - - Converts `SkillDefinition[]` into request instructions, tool schemas, runtime tools, and compiler state. + - Converts `SkillDefinition[]` into request instructions, built-in file runtime tools, and compiler state. - Lives in `packages/kit/src/skills/compiler.ts`. - Plugin Adapter - Connects skill compiler output to message engine lifecycle. @@ -52,6 +54,7 @@ This repository uses pnpm for dependency and script management. Prefer `pnpm` co - Loader may parse/import skill files into a skill definition, but must not own skill collections. - Manager may call loaders to import skills and may track selected skills, but must not compile request messages/tools. - Public skill APIs should be exported from `packages/kit/src/skills/index.ts`. +- Node-only skill APIs should use dedicated subpath exports instead of the browser package root. - `message/plugins/index.ts` must only export message plugin APIs; skill core APIs belong to `src/skills`. ## Current Public API Shape @@ -65,9 +68,9 @@ skillPlugin({ Compiler state uses: ```ts -state.skills -state.skillNames -state.runtimeTools +pluginState.skills +pluginState.skillNames +pluginState.runtimeTools ``` ## Important Files diff --git a/docs/.vitepress/themeConfig.ts b/docs/.vitepress/themeConfig.ts index d90911118..3d78ad32b 100644 --- a/docs/.vitepress/themeConfig.ts +++ b/docs/.vitepress/themeConfig.ts @@ -36,6 +36,7 @@ const sharedSidebarItems = [ items: [ { text: 'useMessage 消息数据管理', link: 'message' }, { text: 'useConversation 会话数据管理', link: 'conversation' }, + { text: 'Skill 技能工具链', link: 'skill' }, { text: 'AIClient 模型交互工具类', link: 'ai-client' }, { text: '工具函数', link: 'utils' }, { text: 'CLI 命令行工具', link: 'cli' }, diff --git a/docs/demos/tools/skill/SkillInspector.css b/docs/demos/tools/skill/SkillInspector.css new file mode 100644 index 000000000..f1be68c19 --- /dev/null +++ b/docs/demos/tools/skill/SkillInspector.css @@ -0,0 +1,278 @@ +.skill-inspector { + container-type: inline-size; + display: grid; + grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); + gap: 16px; +} + +.skill-inspector .panel { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--vp-c-bg); + padding: 16px; +} + +.skill-inspector .panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.skill-inspector .panel-heading h3 { + margin: 0 0 4px; + font-size: 16px; +} + +.skill-inspector .panel-heading p { + margin: 0; + color: var(--vp-c-text-2); + font-size: 13px; + line-height: 1.6; +} + +.skill-inspector .right-panel { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0; + padding: 0; + overflow: hidden; +} + +.skill-inspector .right-panel .right-tab-header { + display: flex; + margin: 0; + border-bottom: 1px solid var(--vp-c-divider); +} + +.skill-inspector .right-panel .right-tab-header button { + flex: 1; + padding: 10px 16px; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--vp-c-text-2); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: color 0.15s, border-color 0.15s; +} + +.skill-inspector .right-panel .right-tab-header button:hover { + color: var(--vp-c-text-1); +} + +.skill-inspector .right-panel .right-tab-header button.active { + color: var(--vp-c-brand-1); + border-bottom-color: var(--vp-c-brand-1); +} + +.skill-inspector .right-tab-content { + display: flex; + flex-direction: column; + padding: 16px; +} + +.skill-inspector .right-tab-content pre { + flex: 1; +} + +.skill-inspector .primary-action, +.skill-inspector .tabs button { + border: 1px solid var(--vp-c-divider); + border-radius: 6px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-text-1); + cursor: pointer; + font-size: 13px; + line-height: 1; +} + +.skill-inspector .primary-action { + flex: none; + padding: 8px 12px; +} + +.skill-inspector .primary-action:hover, +.skill-inspector .tabs button:hover, +.skill-inspector .tabs button.active { + border-color: var(--vp-c-brand-1); + color: var(--vp-c-brand-1); +} + +.skill-inspector .directory-picker { + display: flex; + align-items: center; + justify-content: center; + min-height: 72px; + border: 1px dashed var(--vp-c-divider); + border-radius: 8px; + color: var(--vp-c-text-2); + cursor: pointer; + font-size: 14px; +} + +.skill-inspector .directory-picker input { + display: none; +} + +.skill-inspector .error-message { + margin: 10px 0 0; + color: var(--vp-c-danger-1); + font-size: 13px; +} + +.skill-inspector .skill-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; +} + +.skill-inspector .skill-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + cursor: pointer; +} + +.skill-inspector .skill-item input { + flex: none; + margin-top: 3px; + cursor: pointer; +} + +.skill-inspector .skill-item.active { + border-color: var(--vp-c-brand-1); + box-shadow: 0 0 0 1px var(--vp-c-brand-1) inset; + background: var(--vp-c-bg-soft); +} + +.skill-inspector .skill-item strong, +.skill-inspector .skill-item small { + display: block; +} + +.skill-inspector .skill-item small { + margin-top: 4px; + color: var(--vp-c-text-2); + line-height: 1.5; +} + +.skill-inspector .summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; + margin-bottom: 14px; +} + +.skill-inspector .summary-grid div { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + padding: 10px; +} + +.skill-inspector .summary-grid span, +.skill-inspector .summary-grid strong { + display: block; +} + +.skill-inspector .summary-grid span { + color: var(--vp-c-text-2); + font-size: 12px; +} + +.skill-inspector .summary-grid strong { + margin-top: 4px; + word-break: break-word; +} + +.skill-inspector .selected-skills { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 10px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + padding: 10px; +} + +.skill-inspector .selected-skills span { + flex: none; + color: var(--vp-c-text-2); + font-size: 12px; + line-height: 24px; +} + +.skill-inspector .selected-skills div { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.skill-inspector .selected-skills strong { + border-radius: 999px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-brand-1); + padding: 4px 8px; + font-size: 12px; + line-height: 16px; +} + +.skill-inspector .selected-skills em { + color: var(--vp-c-text-3); + font-size: 13px; + font-style: normal; + line-height: 24px; +} + +.skill-inspector .tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.skill-inspector .tabs button { + padding: 7px 10px; +} + +.skill-inspector pre { + min-height: 240px; + max-height: 420px; + margin: 0; + overflow: auto; + border-radius: 8px; + background: var(--vp-code-block-bg); + padding: 14px; + color: var(--vp-code-block-color); + font-size: 13px; + line-height: 1.6; +} + +@media (max-width: 768px) { + .skill-inspector { + grid-template-columns: 1fr; + } + + .skill-inspector .panel-heading { + flex-direction: column; + } + + .skill-inspector .primary-action { + width: 100%; + } +} + +@container (max-width: 720px) { + .skill-inspector { + grid-template-columns: 1fr; + } +} diff --git a/docs/demos/tools/skill/SkillInspector.vue b/docs/demos/tools/skill/SkillInspector.vue new file mode 100644 index 000000000..3f443515e --- /dev/null +++ b/docs/demos/tools/skill/SkillInspector.vue @@ -0,0 +1,121 @@ + + + diff --git a/docs/demos/tools/skill/VueSkillPlugin.css b/docs/demos/tools/skill/VueSkillPlugin.css new file mode 100644 index 000000000..9175e2377 --- /dev/null +++ b/docs/demos/tools/skill/VueSkillPlugin.css @@ -0,0 +1,137 @@ +.skill-chat-demo { + container-type: inline-size; + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.skill-chat-demo .chat-area { + display: flex; + flex-direction: column; + min-height: 400px; +} + +.skill-chat-demo .chat-area > :first-child { + flex: 1; + max-height: 480px; +} + +.skill-chat-demo .skill-sidebar { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--vp-c-bg); + padding: 14px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.skill-chat-demo .sidebar-section { + min-width: 0; +} + +.skill-chat-demo .sidebar-section h3 { + font-size: 14px; + margin: 0 0 4px; +} + +.skill-chat-demo .sidebar-hint { + color: var(--vp-c-text-2); + font-size: 12px; + line-height: 1.5; + margin: 0 0 10px; +} + +.skill-chat-demo .skill-options { + display: flex; + flex-direction: column; + gap: 6px; +} + +.skill-chat-demo .skill-option { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px; + border: 1px solid var(--vp-c-divider); + border-radius: 6px; + cursor: pointer; +} + +.skill-chat-demo .skill-option input { + flex: none; + margin-top: 2px; +} + +.skill-chat-demo .skill-option strong, +.skill-chat-demo .skill-option small { + display: block; +} + +.skill-chat-demo .skill-option strong { + font-size: 12px; +} + +.skill-chat-demo .skill-option small { + margin-top: 2px; + color: var(--vp-c-text-2); + font-size: 11px; + line-height: 1.4; +} + +.skill-chat-demo .selected-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-bottom: 10px; +} + +.skill-chat-demo .selected-summary span { + color: var(--vp-c-text-2); + font-size: 11px; +} + +.skill-chat-demo .selected-summary strong { + border-radius: 999px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-brand-1); + padding: 2px 6px; + font-size: 11px; + line-height: 16px; +} + +.skill-chat-demo .selected-summary em { + color: var(--vp-c-text-3); + font-size: 12px; + font-style: normal; +} + +.skill-chat-demo .subsection-title { + margin: 0 0 4px; + font-size: 12px; + color: var(--vp-c-text-2); +} + +.skill-chat-demo .sidebar-pre { + margin: 0 0 10px; + overflow: auto; + border-radius: 6px; + background: var(--vp-code-block-bg); + padding: 8px; + color: var(--vp-code-block-color); + font-size: 11px; + line-height: 1.5; + min-height: 60px; + max-height: 150px; +} + +.skill-chat-demo .sidebar-pre:last-child { + margin-bottom: 0; +} + +@container (max-width: 640px) { + .skill-chat-demo .skill-sidebar { + grid-template-columns: 1fr; + } +} diff --git a/docs/demos/tools/skill/VueSkillPlugin.vue b/docs/demos/tools/skill/VueSkillPlugin.vue new file mode 100644 index 000000000..90068b9e0 --- /dev/null +++ b/docs/demos/tools/skill/VueSkillPlugin.vue @@ -0,0 +1,183 @@ + + + diff --git a/docs/demos/tools/skill/exampleSkillFiles.ts b/docs/demos/tools/skill/exampleSkillFiles.ts new file mode 100644 index 000000000..a946981e1 --- /dev/null +++ b/docs/demos/tools/skill/exampleSkillFiles.ts @@ -0,0 +1,22 @@ +import type { SkillFile } from '@opentiny/tiny-robot-kit/core' + +export const exampleSkillFiles: SkillFile[] = [ + { + path: 'SKILL.md', + kind: 'text', + content: `--- +name: weather +description: Answer weather questions with concise current conditions and forecast guidance. +--- + +# Weather Skill + +Use this skill when the user asks about weather, temperature, rain, wind, or forecast. +Always mention the target location and keep the answer concise.`, + }, + { + path: 'references/weather-format.md', + kind: 'text', + content: 'Return current condition first, then list the next forecast point when available.', + }, +] diff --git a/docs/demos/tools/skill/useSkillInspector.ts b/docs/demos/tools/skill/useSkillInspector.ts new file mode 100644 index 000000000..a65780ade --- /dev/null +++ b/docs/demos/tools/skill/useSkillInspector.ts @@ -0,0 +1,129 @@ +import { + SkillManager, + compileSkillInstructions, + createSkillFileRuntimeTools, + loadSkillFilesFromFileList, +} from '@opentiny/tiny-robot-kit/core' +import type { SkillDefinition, SkillFile } from '@opentiny/tiny-robot-kit/core' +import { computed, ref, watch } from 'vue' +import { exampleSkillFiles } from './exampleSkillFiles' + +export const compilerOutputTabs = [ + { label: 'Instructions', value: 'instructions' }, + { label: 'Runtime tools', value: 'tools' }, +] as const + +type CompilerOutputTab = (typeof compilerOutputTabs)[number]['value'] + +export const useSkillInspector = () => { + const manager = new SkillManager() + const skills = ref([]) + const selectedSkillNames = ref([]) + const inspectedSkillName = ref('') + const compilerTab = ref('instructions') + const rightTab = ref<'skill' | 'compiler'>('skill') + const errorMessage = ref('') + const compiledInstructionsText = ref('') + + const syncManagerState = () => { + skills.value = manager.list() + selectedSkillNames.value = manager.getSelectedSkillNames() + } + + const importSkillFiles = (files: SkillFile[]) => { + errorMessage.value = '' + + try { + const result = manager.import(files) + manager.select(result.skill.name) + inspectedSkillName.value = result.skill.name + syncManagerState() + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : String(error) + } + } + + const loadExampleSkill = () => { + importSkillFiles(exampleSkillFiles) + } + + const handleDirectoryChange = async (event: Event) => { + const input = event.target as HTMLInputElement + if (!input.files?.length) { + return + } + + try { + importSkillFiles(await loadSkillFilesFromFileList(input.files)) + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : String(error) + } finally { + input.value = '' + } + } + + const toggleSkill = (skillName: string, checked: boolean) => { + if (checked) { + manager.select(skillName) + } else { + manager.unselect(skillName) + } + + syncManagerState() + } + + const inspectSkill = (skillName: string) => { + inspectedSkillName.value = skillName + } + + const toggleSkillFromEvent = (skillName: string, event: Event) => { + toggleSkill(skillName, (event.target as HTMLInputElement).checked) + } + + const selectedSkills = computed(() => + selectedSkillNames.value.flatMap((skillName) => { + const skill = manager.get(skillName) + return skill ? [skill] : [] + }), + ) + + const inspectedSkill = computed(() => { + return manager.get(inspectedSkillName.value) ?? skills.value[0] + }) + + const inspectedDefinitionJson = computed(() => JSON.stringify(inspectedSkill.value ?? null, null, 2)) + + const compiledToolsJson = computed(() => { + const tools = createSkillFileRuntimeTools(selectedSkills.value).map((runtimeTool) => runtimeTool.tool) + return JSON.stringify(tools, null, 2) + }) + + watch( + selectedSkills, + async (currentSkills) => { + const message = await compileSkillInstructions(currentSkills) + compiledInstructionsText.value = message ? JSON.stringify(message, null, 2) : 'undefined' + }, + { immediate: true }, + ) + + loadExampleSkill() + + return { + compilerTab, + compilerTabs: compilerOutputTabs, + compiledInstructionsText, + compiledToolsJson, + errorMessage, + handleDirectoryChange, + inspectSkill, + inspectedDefinitionJson, + inspectedSkill, + inspectedSkillName, + loadExampleSkill, + rightTab, + selectedSkillNames, + skills, + toggleSkillFromEvent, + } +} diff --git a/docs/src/tools/skill.md b/docs/src/tools/skill.md new file mode 100644 index 000000000..7fccb086b --- /dev/null +++ b/docs/src/tools/skill.md @@ -0,0 +1,312 @@ +--- +outline: [1, 3] +--- + +# Skill 技能工具链 + +Skill 是一组可复用的能力模板。一个 skill 至少包含名称、描述和指令,也可以携带工具声明和文件资源。`@opentiny/tiny-robot-kit` 中的 skill 工具链分为三层: + +- **File Adapters**:把不同平台的文件来源转换为统一的 `SkillFile[]`。 +- **Loader / Manager**:把 `SkillFile[]` 解析为 `SkillDefinition`,并管理 skill 集合与选择状态。 +- **Compiler**:把已选 `SkillDefinition[]` 编译为 message engine 可消费的 instructions 和运行时文件工具。 + +## 基本数据模型 + +```typescript +interface SkillDefinition { + name: string + description: string + instructions: string + files?: SkillFileResource[] + metadata?: Record +} +``` + +- `name`:skill 唯一名称,用于去重、选择和读取文件资源。 +- `description`:能力描述,适合用于 UI 展示、搜索或后续自动选择 skill。 +- `instructions`:注入模型请求的核心指令,必填。 +- `files`:skill 目录中的附加文件资源,可通过基础文件工具读取。 + +## Loader + +Loader 的职责是把标准化后的 `SkillFile[]` 解析为 `SkillDefinition`。它不负责读取本地文件、浏览器文件或远程资源;这些工作由 file adapters 完成。 + +### Node.js 目录加载 + +`loadSkillFilesFromFs` 会把本地目录读取为 `SkillFile[]`,再交给 `SkillLoader` 解析。 + +```typescript +import { SkillLoader } from '@opentiny/tiny-robot-kit/core' +import { loadSkillFilesFromFs } from '@opentiny/tiny-robot-kit/node' + +const files = await loadSkillFilesFromFs('/path/to/weather-skill') +const result = new SkillLoader().load(files) + +console.log(result.skill.name) +console.log(result.skill.description) +console.log(result.skill.instructions) +console.log(result.warnings) +``` + +### Browser 文件加载 + +浏览器侧可以把 `` 选择出的文件列表转换为 `SkillFile[]`。 + +```typescript +import { SkillLoader, loadSkillFilesFromFileList } from '@opentiny/tiny-robot-kit/core' + +async function importFromInput(input: HTMLInputElement) { + if (!input.files) { + return + } + + const files = await loadSkillFilesFromFileList(input.files) + return new SkillLoader().load(files) +} +``` + +如果使用 `window.showDirectoryPicker()`,可以使用 `loadSkillFilesFromDirectoryHandle`: + +```typescript +import { SkillLoader, loadSkillFilesFromDirectoryHandle } from '@opentiny/tiny-robot-kit/core' + +const directoryHandle = await window.showDirectoryPicker() +const files = await loadSkillFilesFromDirectoryHandle(directoryHandle) +const result = new SkillLoader().load(files) +``` + +### SKILL.md 结构 + +`SkillLoader` 默认读取 `SKILL.md` 作为入口文件。frontmatter 中的 `name` 和 `description` 会写入 `SkillDefinition`,正文会作为必填 `instructions`。 + +````markdown +--- +name: weather +description: Get current weather and forecast information. +homepage: https://wttr.in/:help +--- + +# Weather Skill + +Use wttr.in when the user asks about current weather or forecasts. +Prefer concise answers and include the location in the response. +```` + +### Warning 和严格模式 + +非致命问题会放到 `SkillLoaderResult.warnings` 中,例如重复路径、无法解析工具文件等。 + +```typescript +const result = new SkillLoader().load(files) + +for (const warning of result.warnings) { + console.warn(warning.code, warning.path, warning.message) +} +``` + +如果希望 warning 直接抛出为错误,可以启用严格模式: + +```typescript +const result = new SkillLoader({ strict: true }).load(files) +``` + +## Manager + +`SkillManager` 是框架无关的 skill 集合管理工具。它只负责保存、删除、导入、选择 skills,不编译 prompt 或 tools,也不接入 message 生命周期。 + +### 管理 skill 集合 + +```typescript +import { SkillManager } from '@opentiny/tiny-robot-kit/core' + +const manager = new SkillManager() + +manager.set({ + name: 'weather', + description: 'Get current weather information.', + instructions: 'Use weather context when the user asks about weather.', +}) + +console.log(manager.has('weather')) // true +console.log(manager.get('weather')) +console.log(manager.list()) + +manager.remove('weather') +``` + +`set(skill)` 是唯一写入入口:不存在时新增,同名存在时覆盖。 + +### 选择本次请求使用的 skills + +manager 内部可以维护选择状态。这个状态适合由 UI 或业务逻辑驱动,再交给 `skillPlugin` 读取。 + +```typescript +manager.set(weatherSkill) +manager.set(vueSkill) + +manager.select(['weather', 'vue-best-practices']) + +const selectedSkills = manager.getSelectedSkills() +const selectedSkillNames = manager.getSelectedSkillNames() + +manager.unselect('weather') +``` + +选择不存在的 skill 会抛错: + +```typescript +manager.select('missing-skill') // throws +``` + +### 导入 skill + +`SkillManager.import()` 会复用 `SkillLoader`,把 `SkillFile[]` 导入为 skill 并写入 manager。 + +```typescript +import { SkillManager } from '@opentiny/tiny-robot-kit/core' +import { loadSkillFilesFromFs } from '@opentiny/tiny-robot-kit/node' + +const manager = new SkillManager() +const files = await loadSkillFilesFromFs('/path/to/weather-skill') +const result = manager.import(files) + +console.log(result.skill.name) +console.log(manager.get(result.skill.name)) +``` + +可以透传 `SkillLoaderOptions`: + +```typescript +manager.import(files, { + entryFile: 'README.md', + strict: true, +}) +``` + +### 搭配 skillPlugin + +`SkillManager` 可以和 `skillPlugin` 一起使用。manager 负责选择,`skillPlugin` 负责把已选 skills 编译进 message engine。 + +```typescript +import { skillPlugin, useMessage } from '@opentiny/tiny-robot-kit' +import { SkillManager } from '@opentiny/tiny-robot-kit/core' + +const manager = new SkillManager({ + skills: [weatherSkill, vueSkill], + selectedSkillNames: ['weather'], +}) + +const message = useMessage({ + responseProvider, + plugins: [ + skillPlugin({ + getSkills: () => manager.getSelectedSkills(), + }), + ], +}) +``` + +在 Vue 中也可以直接传入响应式的 skills: + + + +## Compiler + +Compiler 是纯转换层:输入 `SkillDefinition[]`,输出 message engine 可消费的 instructions 和运行时文件工具。 + +### 编译 instructions + +```typescript +import { compileSkillInstructions } from '@opentiny/tiny-robot-kit/core' + +const systemMessage = await compileSkillInstructions([weatherSkill, vueSkill]) +``` + +编译后的结果是一个 system message: + +```typescript +{ + role: 'system', + content: 'Apply these skill instructions when generating the response.\n\n## weather\n\n...' +} +``` + +空白 instructions 会被忽略。如果没有任何可用 instructions,则返回 `undefined`。 + +### 创建基础文件工具 + +```typescript +import { createSkillFileRuntimeTools } from '@opentiny/tiny-robot-kit/core' + +const runtimeTools = createSkillFileRuntimeTools([docsSkill]) +``` + +当任意 skill 带有 `files` 时,会生成两个基础 runtime tools: + +- `list_skill_files`:列出当前 skills 携带的文件资源。 +- `read_skill_file`:按 `skillName` 和相对路径读取文本文件内容。 + +```typescript +import { createSkillFileRuntimeTools } from '@opentiny/tiny-robot-kit/core' + +const runtimeTools = createSkillFileRuntimeTools([docsSkill]) +const [listFiles, readFile] = runtimeTools + +const listed = await listFiles.handler( + { + id: 'call_1', + type: 'function', + function: { + name: 'list_skill_files', + arguments: JSON.stringify({ skillName: 'docs' }), + }, + }, + {} as never, +) + +const content = await readFile.handler( + { + id: 'call_2', + type: 'function', + function: { + name: 'read_skill_file', + arguments: JSON.stringify({ + skillName: 'docs', + path: 'references/guide.md', + }), + }, + }, + {} as never, +) +``` + +二进制文件不会返回内容,只返回文件摘要和 `binary_file_not_readable` 错误。 + +## 与 message 插件体系的关系 + +`skillPlugin` 是 message runtime adapter。它不加载、不选择、不缓存、不管理 skills,只通过 `getSkills()` 接收本次请求要使用的 skills。 + +内部流程: + +1. `onTurnStart`:读取 `getSkills()`,创建基础文件工具,并把 skills 与 runtime tools 写入 `customContext.__tiny_robot_skill`。 +2. `provideTools`:从插件状态中读取 runtime tools,并暴露给 message engine。 +3. `onBeforeRequest`:调用 `compileSkillInstructions(skills)`,把 system message 插入到请求消息最前面。 + +```typescript +import { skillPlugin, useMessage } from '@opentiny/tiny-robot-kit' + +useMessage({ + responseProvider, + plugins: [ + skillPlugin({ + getSkills: () => manager.getSelectedSkills(), + }), + ], +}) +``` diff --git a/packages/kit/package.json b/packages/kit/package.json index 2fcede50e..313eb3c88 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -44,6 +44,11 @@ "types": "./dist/core.d.ts", "import": "./dist/core.mjs", "require": "./dist/core.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.mjs", + "require": "./dist/node.js" } }, "files": [ @@ -51,8 +56,8 @@ ], "sideEffects": false, "scripts": { - "build": "tsup src/index.ts src/core.ts --format cjs,esm --dts --minify", - "dev": "tsup src/index.ts src/core.ts --format cjs,esm --dts --watch", + "build": "tsup src/index.ts src/core.ts src/node.ts --format cjs,esm --dts --minify", + "dev": "tsup src/index.ts src/core.ts src/node.ts --format cjs,esm --dts --watch", "lint": "eslint src", "pretest": "node scripts/download-skill-fixtures.mjs", "test": "vitest run", diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index 7cb3d2fb8..1456bf582 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -1,7 +1,6 @@ export { AIClient } from './client' export { BaseModelProvider } from './providers/base' export { OpenAIProvider } from './providers/openai' -export * from './skills' export * from './storage' export * from './types' export { extractTextFromResponse, formatMessages, handleSSEStream, sseStreamToGenerator } from './utils' diff --git a/packages/kit/src/message/plugins/skillPlugin.ts b/packages/kit/src/message/plugins/skillPlugin.ts index 2763bccf2..868ab3414 100644 --- a/packages/kit/src/message/plugins/skillPlugin.ts +++ b/packages/kit/src/message/plugins/skillPlugin.ts @@ -1,16 +1,19 @@ +import { compileSkillInstructions, createSkillFileRuntimeTools } from '../../skills/compiler' +import type { SkillDefinition } from '../../skills/types' import type { MaybePromise } from '../../types' import type { BasePluginContext, MessageEnginePlugin } from '../types' -import { compileSkillInstructions, compileSkillTools, createSkillCompilerState } from '../../skills/compiler' -import type { SkillCompilerState } from '../../skills/compiler' -import type { SkillDefinition } from '../../skills/types' -import type { ToolProvider } from './toolPlugin' +import type { RuntimeTool, ToolProvider } from './toolPlugin' /** * Skill 插件的转换状态。 * * 该状态会写入 customContext.__tiny_robot_skill,供消息钩子和插件回调读取同一份编译结果。 */ -export type SkillPluginState = SkillCompilerState +export interface SkillPluginState { + skills: SkillDefinition[] + skillNames: string[] + runtimeTools: RuntimeTool[] +} /** * 将已选择的 skills 转换为消息指令和工具的配置项。 @@ -38,10 +41,15 @@ export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin & ...restOptions, provideTools: async (context: BasePluginContext) => { const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined - return state ? compileSkillTools(state) : [] + return state?.runtimeTools ?? [] }, onTurnStart: async (context) => { - const state = createSkillCompilerState((await getSkills?.(context)) ?? []) + const skills = (await getSkills?.(context)) ?? [] + const state: SkillPluginState = { + skills, + skillNames: skills.map((skill) => skill.name), + runtimeTools: createSkillFileRuntimeTools(skills), + } context.setCustomContext({ [skillPluginContextKey]: state }) diff --git a/packages/kit/src/node.ts b/packages/kit/src/node.ts new file mode 100644 index 000000000..260b394bf --- /dev/null +++ b/packages/kit/src/node.ts @@ -0,0 +1,2 @@ +export { loadSkillFilesFromFs } from './skills/fsSkillFiles' +export type { FsSkillFilesOptions } from './skills/fsSkillFiles' diff --git a/packages/kit/src/skills/README.md b/packages/kit/src/skills/README.md index ccfdc3e27..c7808e278 100644 --- a/packages/kit/src/skills/README.md +++ b/packages/kit/src/skills/README.md @@ -1,149 +1,185 @@ -# Skill Message Plugin Flow +# Skill Toolchain Architecture -本文档说明 skill 如何通过 `skillPlugin` 接入 message 插件体系,以及关键数据在 loader、compiler、plugin hook 之间的流转方式。 +本文档面向维护者,用于说明 `packages/kit/src/skills` 中各模块的职责边界。面向使用者的 API 文档和交互示例放在 `docs/src/tools/skill.md`。 -## 核心边界 +## 目标 -- `SkillDefinition` 是 skill 的能力模板,包含 `name`、`description`、`instructions`、`tools`、`files` 和 `metadata`。 -- `fsSkillFiles` 和 `browserSkillFiles` 是文件适配器,只负责把平台文件源转换为 `SkillFile[]`。 -- `SkillLoader` 负责把 `SkillFile[]` 解析为 `SkillDefinition`。 -- `skillPlugin` 不加载、不选择、不缓存、不管理 skills,只通过 `getSkills()` 接收本次请求要使用的 skills。 -- `compiler` 只负责把 `SkillDefinition[]` 转换为 message engine 可消费的 instructions、tools 和 compiler state。 -- `SkillManager` 只负责 skill 集合和选择状态,不编译 instructions 或 tools。 -- `message` 侧只通过插件 hook 消费 compiler 输出。 +skill 是一组可复用的能力模板。它可以从文件加载,被业务侧管理和选择,并在请求前转换为模型可消费的 instructions 和基础文件工具。 -## 流程图 +skill 核心能力不依赖 Vue,浏览器安全 API 从 `@opentiny/tiny-robot-kit/core` 导出;Node 文件系统能力从 `@opentiny/tiny-robot-kit/node` 导出。 -```mermaid -flowchart TD - A["外部逻辑
    UI / selector / manager / 调用方"] -->|"getSkills()"| B["skillPlugin.onTurnStart"] - B -->|"SkillDefinition[]"| C["createSkillCompilerState(skills)"] - C --> D["uniqueSkills(skills)
    按 name 去重,保留第一个"] - D --> E["createSkillFileRuntimeTools(skills)"] - E -->|"有 files 时生成"| F["runtimeTools
    list_skill_files
    read_skill_file"] - E -->|"无 files"| G["runtimeTools = undefined"] - F --> H["SkillCompilerState"] - G --> H - D --> H - H -->|"setCustomContext"| I["customContext.__tiny_robot_skill"] - I --> J["skillPlugin.provideTools"] - I --> K["skillPlugin.onBeforeRequest"] - J -->|"compileSkillTools(state)"| L["message tools"] - L --> L1["基础文件工具
    state.runtimeTools"] - L --> L2["业务 skill 工具
    skill.tools"] - K -->|"compileSkillInstructions(state.skills)"| M["system message"] - M -->|"prepend"| N["requestBody.messages"] +## 核心数据 + +`SkillDefinition` 是 loader、manager、compiler 之间流转的核心数据结构: + +```ts +interface SkillDefinition { + name: string + description: string + instructions: string + files?: SkillFileResource[] + metadata?: Record +} ``` -## 关键数据处理 +- `name`:skill 唯一名称,由 manager 负责集合层面的覆盖和选择。 +- `description`:用于展示、搜索或后续自动选择。 +- `instructions`:注入模型请求的核心指令。 +- `files`:随 skill 携带的附加文件资源,可由基础文件工具读取。 +- `metadata`:应用侧和 loader 保留的扩展信息。 -### `getSkills()` +## 模块职责 -`getSkills()` 由调用方提供,返回本次请求要使用的 `SkillDefinition[]`。返回值已经代表被选中的 skills,插件内部不保留 `activeSkills` 语义。 +### `types.ts` -```ts -skillPlugin({ - getSkills: () => [skill], -}) -``` +定义 skill 工具链的共享类型: -### `SkillCompilerState` +- `SkillDefinition` +- `SkillFile` +- `SkillFileResource` +- 文本和二进制 skill 文件类型 -`onTurnStart` 会把 `getSkills()` 的结果转换为 compiler state,并写入 `customContext.__tiny_robot_skill`。 +该文件不包含运行逻辑。 -```ts -type SkillCompilerState = { - skills: SkillDefinition[] - skillNames: string[] - runtimeTools?: RuntimeTool[] -} -``` +### `utils.ts` -- `skills`:去重后的 skill 列表。 -- `skillNames`:从 `skills` 提取的名称列表,便于展示、日志或调试。 -- `runtimeTools`:由 skill 文件资源生成的基础文件工具。 +提供 skill 文件路径和文本文件判断工具: -### Instructions +- `normalizeSkillPath` +- `isTextSkillFilePath` +- `getExtension` -`onBeforeRequest` 从 compiler state 中读取 `state.skills`,调用 `compileSkillInstructions(state.skills)`。 +这些工具只处理文件路径和扩展名,不解析 skill 语义。 -处理规则: +### `browserSkillFiles.ts` -- 只读取 `skill.instructions` 字符串。 -- 空字符串或只包含空白的 instructions 会被忽略。 -- 每个 skill 的 instructions 会按 `## skill.name` 分段。 -- 编译结果作为 system message 插入到 `requestBody.messages` 最前面。 +浏览器文件适配器。负责把浏览器文件来源转换为标准 `SkillFile[]`: -### Tools +- `loadSkillFilesFromFileList` +- `loadSkillFilesFromDirectoryHandle` -`provideTools` 从 compiler state 中读取 state,调用 `compileSkillTools(state)`。 +该模块只读取文件内容并标准化路径,不解析 `SKILL.md`。 -工具来源分为两类: +### `fsSkillFiles.ts` -- 基础文件工具:由 `createSkillFileRuntimeTools(skills)` 根据 `skill.files` 创建。 -- 业务 skill 工具:由 `skill.tools` 提供,是静态工具数组。 +Node 文件适配器。负责把本地目录转换为标准 `SkillFile[]`: -`compileSkillTools(state)` 会先返回基础文件工具,再返回业务 skill 工具。 +- `loadSkillFilesFromFs` -### 基础文件工具 +该模块依赖 Node `fs/path`,只能从 `@opentiny/tiny-robot-kit/node` 子入口导出,不能从浏览器根入口导出。 -当任意 skill 带有 `files` 时,compiler 会创建两个基础 runtime tools: +### `skillLoader.ts` -- `list_skill_files`:列出当前 skills 携带的文件资源。 -- `read_skill_file`:按 `skillName` 和相对路径读取文本文件内容。 +loader 层。负责把 `SkillFile[]` 解析为 `SkillDefinition`: -二进制文件只返回文件摘要,不返回内容。 +- 查找入口文件,默认 `SKILL.md` +- 解析 frontmatter +- 将正文转换为必填 `instructions` +- 将其他支持的文件转换为 `files` +- 收集非致命 warnings -## Hook 对应关系 +loader 不负责: -| Hook | 输入 | 处理 | 输出 | -| --- | --- | --- | --- | -| `onTurnStart` | `getSkills()` | 创建 `SkillCompilerState` 并写入 `customContext` | `customContext.__tiny_robot_skill` | -| `provideTools` | `SkillCompilerState` | 编译基础文件工具和业务工具 | `ToolProviderItem[]` | -| `onBeforeRequest` | `SkillCompilerState.skills` | 编译 skill instructions | prepend system message | +- 读取文件系统或浏览器文件 +- 保存 skill 集合 +- 选择 skill +- 编译 message 请求 -## SkillManager +### `manager.ts` -`SkillManager` 是框架无关的 skill 集合管理工具。它可以被业务层、组件层 adapter 或测试代码复用,但不依赖 message engine。 +manager 层。负责 skill 集合和选择状态: -基础能力: +- `set(skill)`:新增或覆盖同名 skill +- `remove(name)` / `clear()` +- `get(name)` / `has(name)` / `list()` +- `select(names)` / `unselect(names)` +- `getSelectedSkillNames()` / `getSelectedSkills()` +- `import(files, options)`:通过 `SkillLoader` 导入 skill -- `set(skill)`:写入 skill,同名时覆盖,不存在时新增。 -- `remove(name)`:删除 skill,并从选择状态中移除。 -- `get(name)` / `has(name)` / `list()`:查询 skill。 -- `select(names)` / `unselect(names)`:维护选择状态。 -- `getSelectedSkillNames()` / `getSelectedSkills()`:读取已选 skills。 -- `import(files, options)`:通过 `SkillLoader` 从 `SkillFile[]` 导入 skill。 +manager 不负责编译 instructions 或 runtime tools。 -`skillPlugin` 可以直接读取 manager 选择结果: +### `compiler.ts` -```ts -const manager = new SkillManager() +compiler 层只保留两个纯转换函数: -skillPlugin({ - getSkills: () => manager.getSelectedSkills(), -}) +- `compileSkillInstructions(skills)` +- `createSkillFileRuntimeTools(skills)` + +`compileSkillInstructions` 将已选择的 skills 转换为 system message。 + +`createSkillFileRuntimeTools` 根据 `skill.files` 创建基础文件工具: + +- `list_skill_files` +- `read_skill_file` + +compiler 不负责: + +- skill 去重 +- 选择状态 +- 持久化 +- 集合管理 + +### `index.ts` + +skill core 的统一导出口。该入口会被 `@opentiny/tiny-robot-kit/core` 重新导出。 + +不要在这里导出 Node-only API,例如 `loadSkillFilesFromFs`。 + +## Message 接入 + +message 接入代码不放在 `src/skills` 下: + +- core message adapter:`packages/kit/src/message/plugins/skillPlugin.ts` +- Vue message adapter:`packages/kit/src/vue/message/plugins/skillPlugin.ts` + +`skillPlugin` 的职责是把调用方传入的当前 skills 接入 message 生命周期: + +1. `onTurnStart` 读取 `getSkills()` 或 Vue 侧响应式 `skills`。 +2. 创建 `runtimeTools = createSkillFileRuntimeTools(skills)`。 +3. 将 `{ skills, skillNames, runtimeTools }` 写入 `customContext.__tiny_robot_skill`。 +4. `provideTools` 暴露 `runtimeTools`。 +5. `onBeforeRequest` 调用 `compileSkillInstructions(skills)` 并 prepend system message。 + +`skillPlugin` 不加载、不缓存、不选择、不管理 skill 集合。 + +## 数据流 + +```mermaid +flowchart TD + A["File source
    FileList / DirectoryHandle / fs directory"] --> B["file adapter"] + B -->|"SkillFile[]"| C["SkillLoader"] + C -->|"SkillDefinition"| D["SkillManager"] + D -->|"selected SkillDefinition[]"| E["skillPlugin"] + E --> F["compileSkillInstructions"] + E --> G["createSkillFileRuntimeTools"] + F --> H["system message"] + G --> I["runtime tools"] ``` ## Auto Skill Selection -auto skill selection 是一个独立的 selector 层能力,用于让模型根据用户问题从候选 skills 中选择本次请求要启用的 skills。它不属于 `skillPlugin` 的职责。 +auto skill selection 是一个未来的 selector 层能力,用于让模型根据用户问题从候选 skills 中选择本次请求要启用的 skills。它不属于 `skillPlugin`、compiler 或 manager 的当前职责。 推荐链路: -```txt -用户问题 - -> selector turn: 模型读取候选 skill descriptions,并调用 selectSkills - -> request-local selected skill names - -> execution turn: skillPlugin 读取已选 skills,编译 instructions/tools - -> 模型使用已启用 skills 回答 +```mermaid +sequenceDiagram + participant App as 用户 / 应用 + participant Model as 大模型 + + App->>Model: 用户问题 + 候选 skill descriptions + Model->>App: 调用 selectSkills(skillNames) + App->>App: 记录请求级 selected skill names + App->>App: skillPlugin 编译已选 skills + App->>Model: execution turn + instructions + 基础文件工具 + Model->>App: 基于已启用 skills 生成回答 ``` 职责边界: - `SkillManager` 管理全部可用 skills。 - `SkillSelector` 根据用户问题和候选 skill descriptions 产出本次请求的 selected skill names。 -- `skillPlugin` 只读取 selected `SkillDefinition[]`,并编译 instructions 和 tools。 +- `skillPlugin` 只读取 selected `SkillDefinition[]`,并编译 instructions 和基础文件工具。 - auto selection 的结果应写入请求级状态,例如 `customContext.__tiny_robot_selected_skills`,不能直接写入 manager 的长期选择状态。 selector 阶段只提供候选摘要,不提供完整 instructions: @@ -207,11 +243,8 @@ selectionStatus: 'pending' | 'done' - `done` 阶段不再提供 selector 工具。 - `selectSkills` 每个请求最多调用一次。 -## Manager TODO +## 后续事项 -- P1: 实现 auto skill selection。按本文档的独立 selector 层设计,让模型通过 `selectSkills` 工具选择请求级 skills,再交给 `skillPlugin` 编译。 -- P1: 增加重复 skill 名称的诊断结果,用于 UI 提示或导入报告。 -- P1: 设计持久化 adapter 协议,例如 localStorage、IndexedDB 或远程接口。 -- P2: 增加批量导入结果,支持部分成功、部分失败和 warnings 汇总。 -- P2: 增加 skill 启用状态、标签、来源、版本等管理字段的推荐 schema。 -- P3: 增加 manager 事件或订阅机制,供 UI adapter 做响应式同步。 +- 为 `read_skill_file` 增加大小限制和截断策略。 +- 为重复 skill 名称增加诊断能力,优先放在 manager 或选择逻辑中。 +- 评估 auto skill selection 是否需要独立 selector 层。 diff --git a/packages/kit/src/skills/compiler.ts b/packages/kit/src/skills/compiler.ts index 27cde127c..2c8157c84 100644 --- a/packages/kit/src/skills/compiler.ts +++ b/packages/kit/src/skills/compiler.ts @@ -1,38 +1,7 @@ import type { ChatCompletionSystemMessageParam } from 'openai/resources' -import type { RuntimeTool, ToolProviderItem } from '../message/plugins/toolPlugin' +import type { RuntimeTool } from '../message/plugins/toolPlugin' import type { SkillDefinition, SkillFileResource } from './types' -export interface SkillCompilerState { - /** - * 编译输入中的 skill 定义。 - */ - skills: SkillDefinition[] - /** - * 从编译输入中提取的 skill 名称。 - */ - skillNames: string[] - /** - * 根据 skill 资源生成的运行时工具。 - */ - runtimeTools?: RuntimeTool[] -} - -export const uniqueSkills = (skills: SkillDefinition[]) => { - const result: SkillDefinition[] = [] - const names = new Set() - - for (const skill of skills) { - if (names.has(skill.name)) { - continue - } - - names.add(skill.name) - result.push(skill) - } - - return result -} - const skillFileToolNames = { listSkillFiles: 'list_skill_files', readSkillFile: 'read_skill_file', @@ -174,17 +143,6 @@ export const createSkillFileRuntimeTools = (skills: SkillDefinition[]): RuntimeT ] } -export const createSkillCompilerState = (skills: SkillDefinition[]): SkillCompilerState => { - const uniqueSkillList = uniqueSkills(skills) - const runtimeTools = createSkillFileRuntimeTools(uniqueSkillList) - - return { - skills: uniqueSkillList, - skillNames: uniqueSkillList.map((skill) => skill.name), - runtimeTools: runtimeTools.length ? runtimeTools : undefined, - } -} - export const compileSkillInstructions = async ( skills: SkillDefinition[], ): Promise => { @@ -206,9 +164,3 @@ export const compileSkillInstructions = async ( content: ['Apply these skill instructions when generating the response.', ...instructions].join('\n\n'), } } - -export const compileSkillTools = (state: Pick): ToolProviderItem[] => { - const skillTools = state.skills.flatMap((skill) => skill.tools ?? []) - - return [...(state.runtimeTools ?? []), ...skillTools] -} diff --git a/packages/kit/src/skills/index.ts b/packages/kit/src/skills/index.ts index 5e28c8011..d153f8482 100644 --- a/packages/kit/src/skills/index.ts +++ b/packages/kit/src/skills/index.ts @@ -1,15 +1,6 @@ export { loadSkillFilesFromDirectoryHandle, loadSkillFilesFromFileList } from './browserSkillFiles' export type { BrowserDirectoryHandle, BrowserFile, BrowserFileHandle } from './browserSkillFiles' -export { - compileSkillInstructions, - compileSkillTools, - createSkillCompilerState, - createSkillFileRuntimeTools, - uniqueSkills, -} from './compiler' -export type { SkillCompilerState } from './compiler' -export { loadSkillFilesFromFs } from './fsSkillFiles' -export type { FsSkillFilesOptions } from './fsSkillFiles' +export { compileSkillInstructions, createSkillFileRuntimeTools } from './compiler' export { SkillManager } from './manager' export type { SkillManagerOptions } from './manager' export { SkillLoader } from './skillLoader' diff --git a/packages/kit/src/skills/skillLoader.ts b/packages/kit/src/skills/skillLoader.ts index 69f633205..a4ef3c334 100644 --- a/packages/kit/src/skills/skillLoader.ts +++ b/packages/kit/src/skills/skillLoader.ts @@ -1,4 +1,3 @@ -import type { ChatCompletionFunctionTool } from 'openai/resources' import { parse as parseYaml } from 'yaml' import type { SkillDefinition, SkillFile, SkillFileResource } from './types' import { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' @@ -41,7 +40,7 @@ export interface SkillLoaderOptions { /** * 将标准化后的 skill 文件转换为 SkillDefinition。 * - * 文件来源适配器负责提供 SkillFile[];该 loader 负责解析入口文件、工具声明和资源文件。 + * 文件来源适配器负责提供 SkillFile[];该 loader 负责解析入口文件和资源文件。 */ export class SkillLoader { private entryFile: string @@ -74,7 +73,6 @@ export class SkillLoader { const frontmatterMetadata = getRecord(frontmatter.metadata) const skillFiles: SkillFileResource[] = [] - const tools: ChatCompletionFunctionTool[] = [] for (const file of normalizedFiles) { if (file.path === this.entryFile) { @@ -98,19 +96,6 @@ export class SkillLoader { continue } - if (isToolsFile(file.path)) { - try { - tools.push(...parseTools(file.content)) - } catch (error) { - this.handleWarning(warnings, { - code: 'tools-parse-failed', - message: error instanceof Error ? error.message : String(error), - path: file.path, - }) - } - continue - } - skillFiles.push({ ...file, id: file.path, @@ -122,7 +107,6 @@ export class SkillLoader { name: getString(frontmatter.name) || getFallbackSkillName(this.entryFile), description: getString(frontmatter.description) || '', instructions, - tools: tools.length ? tools : undefined, files: skillFiles.length ? skillFiles : undefined, metadata: { ...frontmatterMetadata, @@ -210,37 +194,6 @@ const getString = (value: unknown) => (typeof value === 'string' ? value : undef const getRecord = (value: unknown) => value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined -const isToolsFile = (path: string) => { - const normalizedPath = path.toLowerCase() - return normalizedPath === 'tools.json' || normalizedPath.startsWith('tools/') -} - -const parseTools = (content: string): ChatCompletionFunctionTool[] => { - const data = JSON.parse(content) - const tools = Array.isArray(data) ? data : [data] - return tools.map((tool, index) => { - if (!isChatCompletionFunctionTool(tool)) { - throw new Error(`Invalid function tool at index ${index}. Skill tools must be ChatCompletionFunctionTool.`) - } - - return tool - }) -} - -const isChatCompletionFunctionTool = (tool: unknown): tool is ChatCompletionFunctionTool => { - if (!tool || typeof tool !== 'object') { - return false - } - - const candidate = tool as Partial - return ( - candidate.type === 'function' && - Boolean(candidate.function) && - typeof candidate.function === 'object' && - typeof candidate.function.name === 'string' - ) -} - const getResourceTitle = (path: string) => { const filename = path.split('/').at(-1) || path const ext = getExtension(filename) diff --git a/packages/kit/src/skills/test/compiler.test.ts b/packages/kit/src/skills/test/compiler.test.ts index 5a99a7acb..bf9a92a4c 100644 --- a/packages/kit/src/skills/test/compiler.test.ts +++ b/packages/kit/src/skills/test/compiler.test.ts @@ -1,36 +1,9 @@ import { describe, expect, it } from 'vitest' -import { - compileSkillInstructions, - compileSkillTools, - createSkillCompilerState, - createSkillFileRuntimeTools, - uniqueSkills, -} from '../compiler' - -const tool = (name: string) => - ({ - type: 'function', - function: { - name, - description: `${name} tool`, - parameters: { - type: 'object', - properties: {}, - }, - }, - }) as const +import { compileSkillInstructions, createSkillFileRuntimeTools } from '../compiler' describe('skill compiler', () => { - it('deduplicates skills by first matching name', () => { - const first = { name: 'weather', description: 'first', instructions: 'first instructions' } - const second = { name: 'weather', description: 'second', instructions: 'second instructions' } - const other = { name: 'vue', description: 'other', instructions: 'other instructions' } - - expect(uniqueSkills([first, second, other])).toEqual([first, other]) - }) - - it('creates compiler state with skill names and file runtime tools', () => { - const state = createSkillCompilerState([ + it('creates file runtime tools when skills have files', () => { + const runtimeTools = createSkillFileRuntimeTools([ { name: 'docs', description: 'Docs skill', @@ -51,9 +24,7 @@ describe('skill compiler', () => { }, ]) - expect(state.skills.map((skill) => skill.name)).toEqual(['docs', 'plain']) - expect(state.skillNames).toEqual(['docs', 'plain']) - expect(state.runtimeTools?.map((runtimeTool) => runtimeTool.tool.function.name)).toEqual([ + expect(runtimeTools.map((runtimeTool) => runtimeTool.tool.function.name)).toEqual([ 'list_skill_files', 'read_skill_file', ]) @@ -97,49 +68,6 @@ describe('skill compiler', () => { ).resolves.toBeUndefined() }) - it('compiles skill tools after runtime tools', () => { - const runtimeTools = createSkillFileRuntimeTools([ - { - name: 'docs', - description: 'Docs skill', - instructions: 'Use docs.', - files: [ - { - id: 'guide.md', - path: 'guide.md', - kind: 'text', - content: '# Guide', - }, - ], - }, - ]) - - const compiledTools = compileSkillTools({ - runtimeTools, - skills: [ - { - name: 'static', - description: 'Static skill', - instructions: 'Use static skill.', - tools: [tool('static_tool')], - }, - { - name: 'extra', - description: 'Extra skill', - instructions: 'Use extra skill.', - tools: [tool('extra_tool')], - }, - ], - }) - - expect(compiledTools.map((toolItem) => ('tool' in toolItem ? toolItem.tool : toolItem).function.name)).toEqual([ - 'list_skill_files', - 'read_skill_file', - 'static_tool', - 'extra_tool', - ]) - }) - it('lists and reads files through built-in runtime tools', () => { const [listFiles, readFile] = createSkillFileRuntimeTools([ { diff --git a/packages/kit/src/skills/test/skillLoader.test.ts b/packages/kit/src/skills/test/skillLoader.test.ts index b6efb84d7..74f217aca 100644 --- a/packages/kit/src/skills/test/skillLoader.test.ts +++ b/packages/kit/src/skills/test/skillLoader.test.ts @@ -176,7 +176,7 @@ describe('SkillLoader', () => { ).toThrow('notes.md: Duplicate skill file path: notes.md') }) - it('parses valid tools and reports invalid tools', () => { + it('keeps json files as regular skill files', () => { const loadedSkill = new SkillLoader().load([ { path: 'SKILL.md', @@ -184,7 +184,7 @@ describe('SkillLoader', () => { content: ['---', 'name: tool-skill', 'description: Tool skill', '---', '', '# Tool'].join('\n'), }, { - path: 'tools.json', + path: 'references/weather-format.json', kind: 'text', content: JSON.stringify({ type: 'function', @@ -200,28 +200,7 @@ describe('SkillLoader', () => { }, ]) - expect(loadedSkill.skill.tools?.map((tool) => tool.function.name)).toEqual(['run_tool']) + expect(loadedSkill.skill.files?.map((file) => file.path)).toEqual(['references/weather-format.json']) expect(loadedSkill.warnings).toEqual([]) - - const invalidSkill = new SkillLoader().load([ - { - path: 'SKILL.md', - kind: 'text', - content: ['---', 'name: invalid-tool-skill', 'description: Invalid tool skill', '---', '', '# Tool'].join('\n'), - }, - { - path: 'tools.json', - kind: 'text', - content: JSON.stringify({ type: 'invalid' }), - }, - ]) - - expect(invalidSkill.skill.tools).toBeUndefined() - expect(invalidSkill.warnings).toMatchObject([ - { - code: 'tools-parse-failed', - path: 'tools.json', - }, - ]) }) }) diff --git a/packages/kit/src/skills/test/skillPlugin.test.ts b/packages/kit/src/skills/test/skillPlugin.test.ts index 564cffce6..ce619b39a 100644 --- a/packages/kit/src/skills/test/skillPlugin.test.ts +++ b/packages/kit/src/skills/test/skillPlugin.test.ts @@ -12,24 +12,12 @@ const createTestMessageEngine = (options: CreateMessageEngineOptions) => createMessageEngine(createNativeMessageAdapter(), options) describe('skillPlugin', () => { - it('injects skill instructions and tools before request', async () => { + it('injects skill instructions before request', async () => { const responseProvider = vi.fn(mockResponseProvider('ok')) - const skillTool = { - type: 'function', - function: { - name: 'get_weather', - description: 'Get weather', - parameters: { - type: 'object', - properties: {}, - }, - }, - } as const const weatherSkill: SkillDefinition = { name: 'weather', description: 'Weather skill', instructions: 'Use wttr.in for weather requests.', - tools: [skillTool], } const engine = createTestMessageEngine({ @@ -54,7 +42,7 @@ describe('skillPlugin', () => { content: expect.stringContaining('Use wttr.in for weather requests.'), }) expect(requestBody.messages[1]).toMatchObject({ role: 'user', content: 'weather in London' }) - expect(requestBody.tools).toEqual([skillTool]) + expect(requestBody.tools).toBeUndefined() }) it('executes built-in skill file runtime tools from turn state', async () => { diff --git a/packages/kit/src/skills/types.ts b/packages/kit/src/skills/types.ts index b895cbc38..9dcfd8b77 100644 --- a/packages/kit/src/skills/types.ts +++ b/packages/kit/src/skills/types.ts @@ -1,5 +1,3 @@ -import type { ChatCompletionFunctionTool } from 'openai/resources' - export type SkillFileKind = 'text' | 'binary' /** @@ -65,10 +63,6 @@ export interface SkillDefinition { * 注入模型请求的指令。 */ instructions: string - /** - * skill 暴露的函数工具。 - */ - tools?: ChatCompletionFunctionTool[] /** * 可供 skill 文件运行时工具读取的文件。 */ diff --git a/packages/kit/src/vue/message/plugins/index.ts b/packages/kit/src/vue/message/plugins/index.ts index 4cad5e41a..25639732a 100644 --- a/packages/kit/src/vue/message/plugins/index.ts +++ b/packages/kit/src/vue/message/plugins/index.ts @@ -1,3 +1,4 @@ export * from './lengthPlugin' +export * from './skillPlugin' export * from './thinkingPlugin' export * from './toolPlugin' diff --git a/packages/kit/src/vue/message/plugins/skillPlugin.ts b/packages/kit/src/vue/message/plugins/skillPlugin.ts new file mode 100644 index 000000000..691ba2f4e --- /dev/null +++ b/packages/kit/src/vue/message/plugins/skillPlugin.ts @@ -0,0 +1,51 @@ +import { isRef, unref } from 'vue' +import type { ComputedRef, Ref } from 'vue' +import { skillPlugin as createCoreSkillPlugin } from '../../../message/plugins' +import type { SkillPluginState } from '../../../message/plugins' +import type { MaybePromise } from '../../../types' +import type { SkillDefinition } from '../../../skills/types' +import type { VueMessagePluginRuntime } from '../types.internal' +import type { BasePluginContext, UseMessagePlugin } from '../types' + +export type VueSkillSource = SkillDefinition[] | undefined +export type VueSkillSourceRef = VueSkillSource | Ref | ComputedRef + +export type UseMessageSkillPluginOptions = UseMessagePlugin & { + /** + * 当前请求要使用的 skills。支持普通数组、ref 或 computed。 + */ + skills?: VueSkillSourceRef + /** + * 动态返回当前请求要使用的 skills。 + */ + getSkills?: (context: BasePluginContext) => MaybePromise + /** + * skills 解析并转换为插件状态后触发。 + */ + onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise +} + +const resolveSkillSource = (source: VueSkillSourceRef): VueSkillSource => { + return isRef(source) ? (unref(source) as VueSkillSource) : source +} + +export const skillPlugin = (options: UseMessageSkillPluginOptions): UseMessagePlugin => { + const { skills, getSkills, onSkillsResolved, ...restOptions } = options + + return { + name: 'skill', + __corePluginFactory(runtime: VueMessagePluginRuntime) { + return createCoreSkillPlugin({ + ...runtime.createCorePlugin(restOptions), + getSkills: async (context) => { + const vueContext = runtime.createVueBaseContext(context) + const skillSource = getSkills ? await getSkills(vueContext) : skills + return resolveSkillSource(skillSource) + }, + onSkillsResolved: onSkillsResolved + ? (state, context) => onSkillsResolved(state, runtime.createVueBaseContext(context)) + : undefined, + }) + }, + } as UseMessagePlugin +} diff --git a/packages/kit/src/vue/message/useMessage.test.ts b/packages/kit/src/vue/message/useMessage.test.ts index 4a05871ba..ab8a96559 100644 --- a/packages/kit/src/vue/message/useMessage.test.ts +++ b/packages/kit/src/vue/message/useMessage.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import type { SkillDefinition } from '../../skills/types' import type { ChatMessage } from '../../types' import { mockResponseProvider, mockSequentialResponseProvider } from './mockResponseProvider' import { lengthPlugin } from './plugins/lengthPlugin' +import { skillPlugin } from './plugins/skillPlugin' import { toolPlugin } from './plugins/toolPlugin' import type { ResponseProvider } from './types' import { useMessage } from './useMessage' @@ -191,4 +194,43 @@ describe('useMessage', () => { content: 'done', }) }) + + it('uses vue skillPlugin with reactive skills', async () => { + const skills = ref([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs references.', + files: [ + { + id: 'guide.md', + path: 'guide.md', + kind: 'text', + content: '# Guide', + }, + ], + }, + ]) + const responseProvider = vi.fn(mockResponseProvider('ok')) + + const engine = useMessage({ + responseProvider, + plugins: [ + skillPlugin({ skills }), + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + }) + + await engine.sendMessage('read docs') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Use docs references.'), + }) + expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['list_skill_files', 'read_skill_file']) + }) }) From 4ea86204ad803130cef185ff05cd7a3fa0871316 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Tue, 19 May 2026 20:45:01 +0800 Subject: [PATCH 5/9] feat: enhance skill command execution and runtime tools integration --- AGENTS.md | 31 +++-- docs/src/tools/skill.md | 10 +- .../kit/src/message/plugins/skillPlugin.ts | 19 ++- packages/kit/src/skills/README.md | 114 +++++++++++++++- packages/kit/src/skills/compiler.ts | 123 +++++++++++++++++- packages/kit/src/skills/index.ts | 8 +- packages/kit/src/skills/test/compiler.test.ts | 90 ++++++++++++- .../kit/src/skills/test/skillPlugin.test.ts | 116 ++++++++++++++++- packages/kit/src/skills/types.ts | 2 +- .../src/vue/message/plugins/skillPlugin.ts | 20 ++- 10 files changed, 496 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 60c4b3e87..043c65fa4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,13 @@ The active development track is the skill toolchain in `packages/kit`. -The goal is to make skills a standalone capability template, not a sub-feature of `message`. A skill can be loaded from files, managed by a manager, and compiled into prompt instructions plus built-in file tools for the message engine. +The goal is to make skills a standalone capability template, not a sub-feature of `message`. A skill can be loaded from files, managed by a manager, and compiled into prompt instructions plus runtime tools for the message engine. ## Current Architecture - `packages/kit/src/skills` - Core skill toolchain modules. - - Owns skill loading, skill types, compiler helpers, fixtures, and skill tests. + - Owns skill loading, skill types, compiler helpers, file adapters, manager, and skill tests. - Browser-safe skill APIs are exported from `@opentiny/tiny-robot-kit/core`. - Node-only file adapters are exported from `@opentiny/tiny-robot-kit/node`. - `packages/kit/src/message/plugins/skillPlugin.ts` @@ -33,7 +33,7 @@ This repository uses pnpm for dependency and script management. Prefer `pnpm` co - Converts `SkillFile[]` into `SkillDefinition`. - Lives in `packages/kit/src/skills/skillLoader.ts`. - Compiler - - Converts `SkillDefinition[]` into request instructions, built-in file runtime tools, and compiler state. + - Converts `SkillDefinition[]` into request instructions, built-in file runtime tools, and optional command runtime tools. - Lives in `packages/kit/src/skills/compiler.ts`. - Plugin Adapter - Connects skill compiler output to message engine lifecycle. @@ -41,7 +41,7 @@ This repository uses pnpm for dependency and script management. Prefer `pnpm` co - Manager - Lives in `packages/kit/src/skills/manager.ts`. - Owns write/remove/list/import/select skills. - - Must not compile request messages or tools. + - Must not compile request instructions or runtime tools. ## Hard Rules @@ -49,23 +49,37 @@ This repository uses pnpm for dependency and script management. Prefer `pnpm` co - `skillPlugin` must not own, cache, query, mutate, or manage skill collections. - `skillPlugin` receives the current turn's skills through `getSkills()`. - Do not use `activeSkills` naming in the skill plugin/compiler. The plugin receives skills that are already selected by outside logic. -- Compiler may compile prompts/tools/runtime tools, but must not manage persistence, selection state, or storage. +- Compiler may compile instructions and runtime tools, but must not manage persistence, selection state, or storage. - File adapters may read platform file sources, but must not parse skill semantics. - Loader may parse/import skill files into a skill definition, but must not own skill collections. -- Manager may call loaders to import skills and may track selected skills, but must not compile request messages/tools. +- Manager may call loaders to import skills and may track selected skills, but must not compile request instructions or runtime tools. - Public skill APIs should be exported from `packages/kit/src/skills/index.ts`. - Node-only skill APIs should use dedicated subpath exports instead of the browser package root. - `message/plugins/index.ts` must only export message plugin APIs; skill core APIs belong to `src/skills`. +- Skill command execution uses `executeSkillCommand` on `skillPlugin`. Do not add PPT/PDF/browser/document backends to kit; route command tool calls to application-provided sandbox executors. ## Current Public API Shape ```ts skillPlugin({ getSkills: () => [skill], + executeSkillCommand: async ({ skill, command, args }) => { + return sandboxExecutor.execute({ skill, command, args }) + }, }) ``` -Compiler state uses: +Vue adapter also accepts reactive selected skills: + +```ts +skillPlugin({ + skills: selectedSkills, +}) +``` + +`SkillDefinition` currently contains `name`, `description`, `instructions`, optional `files`, and optional `metadata`. + +Plugin state uses: ```ts pluginState.skills @@ -87,7 +101,6 @@ pluginState.runtimeTools - `packages/kit/src/skills/test/skillLoader.test.ts` - `packages/kit/src/skills/test/skillManager.test.ts` - `packages/kit/src/skills/test/skillPlugin.test.ts` -- `packages/kit/src/skills/test/fixtures` - `packages/kit/src/message/plugins/skillPlugin.ts` ## Validation @@ -104,5 +117,5 @@ pnpm build - Add `read_skill_file` size limits and truncation strategy. - Decide where duplicate skill name diagnostics belong, preferably in manager or selection logic rather than compiler. -- Decide which Manager TODO items in `packages/kit/src/skills/README.md` should be promoted into implementation. +- Decide whether auto skill selection needs an independent selector layer. - Keep manager boundaries separate from compiler boundaries. diff --git a/docs/src/tools/skill.md b/docs/src/tools/skill.md index 7fccb086b..0dc19426e 100644 --- a/docs/src/tools/skill.md +++ b/docs/src/tools/skill.md @@ -4,7 +4,7 @@ outline: [1, 3] # Skill 技能工具链 -Skill 是一组可复用的能力模板。一个 skill 至少包含名称、描述和指令,也可以携带工具声明和文件资源。`@opentiny/tiny-robot-kit` 中的 skill 工具链分为三层: +Skill 是一组可复用的能力模板。一个 skill 至少包含名称、描述和指令,也可以携带文件资源。`@opentiny/tiny-robot-kit` 中的 skill 工具链分为三层: - **File Adapters**:把不同平台的文件来源转换为统一的 `SkillFile[]`。 - **Loader / Manager**:把 `SkillFile[]` 解析为 `SkillDefinition`,并管理 skill 集合与选择状态。 @@ -242,9 +242,9 @@ const systemMessage = await compileSkillInstructions([weatherSkill, vueSkill]) ### 创建基础文件工具 ```typescript -import { createSkillFileRuntimeTools } from '@opentiny/tiny-robot-kit/core' +import { createSkillRuntimeTools } from '@opentiny/tiny-robot-kit/core' -const runtimeTools = createSkillFileRuntimeTools([docsSkill]) +const runtimeTools = createSkillRuntimeTools([docsSkill]) ``` 当任意 skill 带有 `files` 时,会生成两个基础 runtime tools: @@ -253,9 +253,9 @@ const runtimeTools = createSkillFileRuntimeTools([docsSkill]) - `read_skill_file`:按 `skillName` 和相对路径读取文本文件内容。 ```typescript -import { createSkillFileRuntimeTools } from '@opentiny/tiny-robot-kit/core' +import { createSkillRuntimeTools } from '@opentiny/tiny-robot-kit/core' -const runtimeTools = createSkillFileRuntimeTools([docsSkill]) +const runtimeTools = createSkillRuntimeTools([docsSkill]) const [listFiles, readFile] = runtimeTools const listed = await listFiles.handler( diff --git a/packages/kit/src/message/plugins/skillPlugin.ts b/packages/kit/src/message/plugins/skillPlugin.ts index 868ab3414..0062814d7 100644 --- a/packages/kit/src/message/plugins/skillPlugin.ts +++ b/packages/kit/src/message/plugins/skillPlugin.ts @@ -1,4 +1,5 @@ -import { compileSkillInstructions, createSkillFileRuntimeTools } from '../../skills/compiler' +import { compileSkillInstructions, createSkillRuntimeTools } from '../../skills/compiler' +import type { SkillCommandRequest, SkillCommandResult } from '../../skills/compiler' import type { SkillDefinition } from '../../skills/types' import type { MaybePromise } from '../../types' import type { BasePluginContext, MessageEnginePlugin } from '../types' @@ -26,7 +27,15 @@ export type SkillPluginOptions = MessageEnginePlugin & { */ getSkills?: (context: BasePluginContext) => MaybePromise /** - * skills 解析并规整为编译状态后触发。 + * 执行模型为某个 skill 规划的后端命令。 + * + * @experimental 该 API 仍在设计和验证中,命令协议、返回结构和安全边界后续可能调整。 + * + * 插件只负责暴露 execute_skill_command 并转发参数;沙箱、镜像和权限控制由调用方实现。 + */ + executeSkillCommand?: (request: SkillCommandRequest, context: BasePluginContext) => MaybePromise + /** + * skills 解析并规整为插件状态后触发。 */ onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise } @@ -34,7 +43,7 @@ export type SkillPluginOptions = MessageEnginePlugin & { const skillPluginContextKey = '__tiny_robot_skill' export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin & ToolProvider => { - const { getSkills, onSkillsResolved, ...restOptions } = options + const { getSkills, executeSkillCommand, onSkillsResolved, ...restOptions } = options return { name: 'skill', @@ -48,7 +57,9 @@ export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin & const state: SkillPluginState = { skills, skillNames: skills.map((skill) => skill.name), - runtimeTools: createSkillFileRuntimeTools(skills), + runtimeTools: createSkillRuntimeTools(skills, { + executeSkillCommand: executeSkillCommand && ((request) => executeSkillCommand(request, context)), + }), } context.setCustomContext({ [skillPluginContextKey]: state }) diff --git a/packages/kit/src/skills/README.md b/packages/kit/src/skills/README.md index c7808e278..88bd6e488 100644 --- a/packages/kit/src/skills/README.md +++ b/packages/kit/src/skills/README.md @@ -103,15 +103,19 @@ manager 不负责编译 instructions 或 runtime tools。 compiler 层只保留两个纯转换函数: - `compileSkillInstructions(skills)` -- `createSkillFileRuntimeTools(skills)` +- `createSkillRuntimeTools(skills, options?)` `compileSkillInstructions` 将已选择的 skills 转换为 system message。 -`createSkillFileRuntimeTools` 根据 `skill.files` 创建基础文件工具: +`createSkillRuntimeTools` 根据 `skill.files` 创建基础文件工具: - `list_skill_files` - `read_skill_file` +当传入 `options.executeSkillCommand` 时,它会额外创建命令执行工具: + +- `execute_skill_command` + compiler 不负责: - skill 去重 @@ -135,7 +139,7 @@ message 接入代码不放在 `src/skills` 下: `skillPlugin` 的职责是把调用方传入的当前 skills 接入 message 生命周期: 1. `onTurnStart` 读取 `getSkills()` 或 Vue 侧响应式 `skills`。 -2. 创建 `runtimeTools = createSkillFileRuntimeTools(skills)`。 +2. 创建 `runtimeTools = createSkillRuntimeTools(skills, options)`。 3. 将 `{ skills, skillNames, runtimeTools }` 写入 `customContext.__tiny_robot_skill`。 4. `provideTools` 暴露 `runtimeTools`。 5. `onBeforeRequest` 调用 `compileSkillInstructions(skills)` 并 prepend system message。 @@ -151,11 +155,113 @@ flowchart TD C -->|"SkillDefinition"| D["SkillManager"] D -->|"selected SkillDefinition[]"| E["skillPlugin"] E --> F["compileSkillInstructions"] - E --> G["createSkillFileRuntimeTools"] + E --> G["createSkillRuntimeTools"] F --> H["system message"] G --> I["runtime tools"] ``` +## Sandbox Command Execution + +部分 skill 需要专门的后端运行环境才能执行命令,例如 PPT、PDF、浏览器自动化或文档处理。`kit` 不内置这些后端能力;当前设计是让模型根据已启用 skill 的 instructions 自行规划命令和参数,再由应用侧 executor 转发到后端沙箱执行。 + +推荐工具形态: + +```ts +execute_skill_command({ + skillName: string + command: string + args: string[] +}) +``` + +该阶段不要求从 `SKILL.md` 提取命令 allowlist,也不要求 compiler 生成命令枚举。`SKILL.md` 仍然是自然语言说明,模型可以根据说明决定 `command` 和 `args`。 + +职责边界: + +- `createSkillRuntimeTools(skills, { executeSkillCommand })` 创建 `execute_skill_command` runtime tool。 +- `skillPlugin` 在传入 `executeSkillCommand` 时暴露 `execute_skill_command`。 +- 应用侧 executor 负责选择后端运行环境、鉴权、沙箱、超时、日志、产物管理和错误返回。 +- 后端必须把模型返回的 `command` / `args` 视为不可信输入。 + +后端执行约束: + +- 在隔离环境中执行,例如容器、临时 workspace、受限用户或专用任务服务。 +- 使用 argv 方式执行命令,例如 `spawn(command, args, { shell: false })`。 +- 不把 `command` 和 `args` 拼接成 shell 字符串执行。 +- 设置超时、输出大小限制和并发限制。 +- 限制可访问的文件目录和网络能力。 +- 对危险命令、高成本命令或写入性操作保留业务侧确认能力。 + +推荐返回结构: + +```ts +type SkillArtifact = { + id: string + name: string + mimeType?: string + size?: number + url?: string + textAvailable?: boolean + previewAvailable?: boolean + metadata?: Record +} + +type SkillCommandResult = { + ok: boolean + exitCode?: number + stdout?: string + stderr?: string + artifacts?: SkillArtifact[] + error?: { + code: string + message: string + } +} +``` + +### Artifact 产物模型 + +命令执行可能生成 PDF、PPTX、图片、压缩包等二进制文件。这些内容不应通过 tool message 直接传给模型,也不应以 base64 放进 `stdout`。后端沙箱应把文件写入受控的 artifact store,再在 `SkillCommandResult.artifacts` 中返回引用信息。 + +artifact store 可以是: + +- 应用后端的临时目录和下载接口。 +- 对象存储,例如 S3、OSS、MinIO。 +- 专用任务服务提供的产物访问接口。 + +artifact 必须绑定用户、会话、请求或 sandbox run,不能只依赖裸 `artifactId` 做访问控制。`url` 应由应用侧决定是内部代理地址、短期 signed URL,还是仅供前端预览使用的下载地址。 + +推荐链路: + +```mermaid +sequenceDiagram + participant Model as 大模型 + participant App as kit / 应用侧 executor + participant Sandbox as 后端沙箱 + participant Store as Artifact store + + Model->>App: execute_skill_command(skillName, command, args) + App->>Sandbox: 按 skill runtime 执行 argv 命令 + Sandbox->>Store: 写入二进制产物 + Store-->>Sandbox: artifact metadata / url + Sandbox-->>App: stdout / stderr / artifacts + App-->>Model: tool result: artifact 引用和摘要 + Model->>App: 可选:读取 artifact 文本或摘要 + App->>Store: 可选:读取已提取文本 / 预览信息 + Store-->>App: artifact text / info + App-->>Model: 可选:artifact text / info +``` + +后续如果模型需要继续理解产物内容,可以在 `createSkillRuntimeTools` 中扩展 artifact 读取能力,例如: + +- `list_skill_artifacts` +- `get_skill_artifact_info` +- `read_skill_artifact_text` + +这些工具应返回文本、摘要或元数据,不返回原始二进制内容。第一阶段可以只让 `execute_skill_command` 返回 `artifacts` 引用,由前端或应用侧负责展示、下载和预览。 + +后续如果 skill 命令逐渐稳定,可以再引入机器可读 manifest,把自由命令收敛为 command allowlist 和参数 schema。这个 manifest 属于后续增强,不影响当前基于沙箱的第一阶段设计。 + ## Auto Skill Selection auto skill selection 是一个未来的 selector 层能力,用于让模型根据用户问题从候选 skills 中选择本次请求要启用的 skills。它不属于 `skillPlugin`、compiler 或 manager 的当前职责。 diff --git a/packages/kit/src/skills/compiler.ts b/packages/kit/src/skills/compiler.ts index 2c8157c84..388cec2c3 100644 --- a/packages/kit/src/skills/compiler.ts +++ b/packages/kit/src/skills/compiler.ts @@ -1,5 +1,6 @@ import type { ChatCompletionSystemMessageParam } from 'openai/resources' import type { RuntimeTool } from '../message/plugins/toolPlugin' +import type { MaybePromise } from '../types' import type { SkillDefinition, SkillFileResource } from './types' const skillFileToolNames = { @@ -7,6 +8,26 @@ const skillFileToolNames = { readSkillFile: 'read_skill_file', } as const +const skillCommandToolName = 'execute_skill_command' + +export type SkillCommandRequest = { + skillName: string + command: string + args: string[] + skill: SkillDefinition +} + +export type SkillCommandResult = string | Record + +export type SkillCommandExecutor = (request: SkillCommandRequest) => MaybePromise + +export type SkillRuntimeToolsOptions = { + /** + * @experimental 该 API 仍在设计和验证中,命令协议、返回结构和安全边界后续可能调整。 + */ + executeSkillCommand?: SkillCommandExecutor +} + const skillFileTools: Array = [ { type: 'function', @@ -49,6 +70,36 @@ const skillFileTools: Array = [ }, ] +const skillCommandTool: RuntimeTool['tool'] = { + type: 'function', + function: { + name: skillCommandToolName, + description: 'Execute a command in the backend runtime environment for a selected skill.', + parameters: { + type: 'object', + properties: { + skillName: { + type: 'string', + description: 'Name of the current skill that provides the command instructions.', + }, + command: { + type: 'string', + description: 'Command name to execute in the skill backend runtime.', + }, + args: { + type: 'array', + description: 'Command arguments passed as argv items.', + items: { + type: 'string', + }, + }, + }, + required: ['skillName', 'command', 'args'], + additionalProperties: false, + }, + }, +} + const getSkillFileSummary = (skillName: string, file: SkillFileResource) => ({ skillName, id: file.id, @@ -74,10 +125,14 @@ const parseSkillToolArguments = (toolCall: Parameters[0] } } +const normalizeStringArray = (value: unknown) => { + return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined +} + /** * 创建基础的 skill 文件工具,用于列出和读取 skill 携带的文件资源。 */ -export const createSkillFileRuntimeTools = (skills: SkillDefinition[]): RuntimeTool[] => { +const createSkillFileRuntimeTools = (skills: SkillDefinition[]): RuntimeTool[] => { const hasSkillFiles = skills.some((skill) => Boolean(skill.files?.length)) if (!hasSkillFiles) { @@ -143,6 +198,72 @@ export const createSkillFileRuntimeTools = (skills: SkillDefinition[]): RuntimeT ] } +/** + * 创建 skill 命令执行工具,用于把模型规划的命令转交给应用侧后端运行环境。 + */ +const createSkillCommandRuntimeTools = ( + skills: SkillDefinition[], + executeSkillCommand?: SkillCommandExecutor, +): RuntimeTool[] => { + if (!executeSkillCommand || skills.length === 0) { + return [] + } + + const findSkill = (skillName?: unknown) => { + if (typeof skillName !== 'string' || !skillName) { + return undefined + } + + return skills.find((skill) => skill.name === skillName) + } + + return [ + { + tool: skillCommandTool, + handler: async (toolCall) => { + const toolArguments = parseSkillToolArguments(toolCall) + const skill = findSkill(toolArguments.skillName) + const command = typeof toolArguments.command === 'string' ? toolArguments.command : undefined + const args = normalizeStringArray(toolArguments.args) + + if (!skill) { + return { error: 'skill_not_found' } + } + + if (!command) { + return { error: 'command_required', skillName: skill.name } + } + + if (!args) { + return { error: 'args_required', skillName: skill.name, command } + } + + return executeSkillCommand({ + skill, + skillName: skill.name, + command, + args, + }) + }, + }, + ] +} + +/** + * 创建 skill 运行时工具。 + * + * 默认只根据 skill 文件创建基础文件工具;传入 executeSkillCommand 时会额外创建命令执行工具。 + */ +export const createSkillRuntimeTools = ( + skills: SkillDefinition[], + options: SkillRuntimeToolsOptions = {}, +): RuntimeTool[] => { + return [ + ...createSkillFileRuntimeTools(skills), + ...createSkillCommandRuntimeTools(skills, options.executeSkillCommand), + ] +} + export const compileSkillInstructions = async ( skills: SkillDefinition[], ): Promise => { diff --git a/packages/kit/src/skills/index.ts b/packages/kit/src/skills/index.ts index d153f8482..ff71c81a2 100644 --- a/packages/kit/src/skills/index.ts +++ b/packages/kit/src/skills/index.ts @@ -1,6 +1,12 @@ export { loadSkillFilesFromDirectoryHandle, loadSkillFilesFromFileList } from './browserSkillFiles' export type { BrowserDirectoryHandle, BrowserFile, BrowserFileHandle } from './browserSkillFiles' -export { compileSkillInstructions, createSkillFileRuntimeTools } from './compiler' +export { compileSkillInstructions, createSkillRuntimeTools } from './compiler' +export type { + SkillCommandExecutor, + SkillCommandRequest, + SkillCommandResult, + SkillRuntimeToolsOptions, +} from './compiler' export { SkillManager } from './manager' export type { SkillManagerOptions } from './manager' export { SkillLoader } from './skillLoader' diff --git a/packages/kit/src/skills/test/compiler.test.ts b/packages/kit/src/skills/test/compiler.test.ts index bf9a92a4c..6c524ee62 100644 --- a/packages/kit/src/skills/test/compiler.test.ts +++ b/packages/kit/src/skills/test/compiler.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' -import { compileSkillInstructions, createSkillFileRuntimeTools } from '../compiler' +import { compileSkillInstructions, createSkillRuntimeTools } from '../compiler' describe('skill compiler', () => { it('creates file runtime tools when skills have files', () => { - const runtimeTools = createSkillFileRuntimeTools([ + const runtimeTools = createSkillRuntimeTools([ { name: 'docs', description: 'Docs skill', @@ -32,7 +32,7 @@ describe('skill compiler', () => { it('returns no runtime file tools when skills have no files', () => { expect( - createSkillFileRuntimeTools([{ name: 'plain', description: 'Plain skill', instructions: 'Use plain skill.' }]), + createSkillRuntimeTools([{ name: 'plain', description: 'Plain skill', instructions: 'Use plain skill.' }]), ).toEqual([]) }) @@ -69,7 +69,7 @@ describe('skill compiler', () => { }) it('lists and reads files through built-in runtime tools', () => { - const [listFiles, readFile] = createSkillFileRuntimeTools([ + const [listFiles, readFile] = createSkillRuntimeTools([ { name: 'docs', description: 'Docs skill', @@ -131,7 +131,7 @@ describe('skill compiler', () => { }) it('filters listed files by skill name', () => { - const [listFiles] = createSkillFileRuntimeTools([ + const [listFiles] = createSkillRuntimeTools([ { name: 'docs', description: 'Docs skill', @@ -171,7 +171,7 @@ describe('skill compiler', () => { }) it('returns stable errors when reading skill files with invalid arguments', () => { - const [, readFile] = createSkillFileRuntimeTools([ + const [, readFile] = createSkillRuntimeTools([ { name: 'docs', description: 'Docs skill', @@ -202,6 +202,84 @@ describe('skill compiler', () => { path: 'missing.md', }) }) + + it('creates a skill command runtime tool when an executor is provided', async () => { + const [executeCommand] = createSkillRuntimeTools( + [ + { + name: 'ppt', + description: 'Presentation skill', + instructions: 'Use ppt commands.', + metadata: { + runtime: { + id: 'ppt-runtime', + }, + }, + }, + ], + { + executeSkillCommand: async (request) => ({ + ok: true, + runtimeId: request.skill.metadata?.runtime, + command: request.command, + args: request.args, + }), + }, + ) + + expect(executeCommand.tool.function.name).toBe('execute_skill_command') + await expect( + executeCommand.handler( + createToolCall('execute_skill_command', { + skillName: 'ppt', + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }), + {} as never, + ), + ).resolves.toMatchObject({ + ok: true, + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }) + }) + + it('does not create a skill command runtime tool without an executor', () => { + expect( + createSkillRuntimeTools([{ name: 'ppt', description: 'Presentation skill', instructions: 'Use ppt.' }]), + ).toEqual([]) + }) + + it('returns stable errors when executing skill commands with invalid arguments', async () => { + const [executeCommand] = createSkillRuntimeTools( + [{ name: 'ppt', description: 'Presentation skill', instructions: 'Use ppt.' }], + { + executeSkillCommand: async () => ({ ok: true }), + }, + ) + + await expect( + executeCommand.handler(createToolCall('execute_skill_command', { command: 'ppt-render', args: [] }), {} as never), + ).resolves.toEqual({ + error: 'skill_not_found', + }) + await expect( + executeCommand.handler(createToolCall('execute_skill_command', { skillName: 'ppt', args: [] }), {} as never), + ).resolves.toEqual({ + error: 'command_required', + skillName: 'ppt', + }) + await expect( + executeCommand.handler( + createToolCall('execute_skill_command', { skillName: 'ppt', command: 'ppt-render', args: '--input' }), + {} as never, + ), + ).resolves.toEqual({ + error: 'args_required', + skillName: 'ppt', + command: 'ppt-render', + }) + }) }) const createToolCall = (name: string, args: Record) => ({ diff --git a/packages/kit/src/skills/test/skillPlugin.test.ts b/packages/kit/src/skills/test/skillPlugin.test.ts index ce619b39a..10c82fba5 100644 --- a/packages/kit/src/skills/test/skillPlugin.test.ts +++ b/packages/kit/src/skills/test/skillPlugin.test.ts @@ -170,7 +170,121 @@ describe('skillPlugin', () => { expect(requestBody.tools).toBeUndefined() }) - it('resolves compiler state before running custom turn hooks', async () => { + it('executes skill command runtime tools through the provided executor', async () => { + const executeSkillCommand = vi.fn(async (request) => ({ + ok: true, + skillName: request.skillName, + command: request.command, + args: request.args, + runtime: request.skill.metadata?.runtime, + })) + const responseProvider = vi.fn(async (requestBody: MessageRequestBody) => { + const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') + + if (!hasToolResult) { + expect(requestBody.tools?.map((tool) => tool.function.name)).toContain('execute_skill_command') + + return { + id: 'command-call', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { + name: 'execute_skill_command', + arguments: JSON.stringify({ + skillName: 'ppt', + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }), + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + } + } + + expect(JSON.parse(requestBody.messages.at(-1)?.content as string)).toMatchObject({ + ok: true, + skillName: 'ppt', + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }) + + return { + id: 'final-answer', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + } + }) + const pptSkill: SkillDefinition = { + name: 'ppt', + description: 'Presentation skill', + instructions: 'Use ppt commands.', + metadata: { + runtime: { + id: 'ppt-runtime', + }, + }, + } + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + toolPlugin({ + getTools: async () => [], + callTool: async () => { + throw new Error('fallback should not run') + }, + }), + skillPlugin({ + getSkills: () => [pptSkill], + executeSkillCommand, + }), + ], + responseProvider, + }) + + await engine.sendMessage('render ppt') + + expect(executeSkillCommand).toHaveBeenCalledWith( + expect.objectContaining({ + skill: pptSkill, + skillName: 'ppt', + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }), + expect.objectContaining({ + customContext: expect.any(Object), + }), + ) + expect(responseProvider).toHaveBeenCalledTimes(2) + }) + + it('resolves plugin state before running custom turn hooks', async () => { const resolvedState = vi.fn() const turnStart = vi.fn() const weatherSkill: SkillDefinition = { diff --git a/packages/kit/src/skills/types.ts b/packages/kit/src/skills/types.ts index 9dcfd8b77..337c3d4e6 100644 --- a/packages/kit/src/skills/types.ts +++ b/packages/kit/src/skills/types.ts @@ -48,7 +48,7 @@ export type SkillFileResource = SkillFile & { /** * skill 能力模板。 * - * skill 可以提供指令、工具和文件资源,并被编译到消息请求中。 + * skill 可以提供指令和文件资源,并被编译到消息请求中。 */ export interface SkillDefinition { /** diff --git a/packages/kit/src/vue/message/plugins/skillPlugin.ts b/packages/kit/src/vue/message/plugins/skillPlugin.ts index 691ba2f4e..522b70ced 100644 --- a/packages/kit/src/vue/message/plugins/skillPlugin.ts +++ b/packages/kit/src/vue/message/plugins/skillPlugin.ts @@ -1,11 +1,12 @@ -import { isRef, unref } from 'vue' import type { ComputedRef, Ref } from 'vue' -import { skillPlugin as createCoreSkillPlugin } from '../../../message/plugins' +import { isRef, unref } from 'vue' import type { SkillPluginState } from '../../../message/plugins' -import type { MaybePromise } from '../../../types' +import { skillPlugin as createCoreSkillPlugin } from '../../../message/plugins' +import type { SkillCommandRequest, SkillCommandResult } from '../../../skills/compiler' import type { SkillDefinition } from '../../../skills/types' -import type { VueMessagePluginRuntime } from '../types.internal' +import type { MaybePromise } from '../../../types' import type { BasePluginContext, UseMessagePlugin } from '../types' +import type { VueMessagePluginRuntime } from '../types.internal' export type VueSkillSource = SkillDefinition[] | undefined export type VueSkillSourceRef = VueSkillSource | Ref | ComputedRef @@ -19,6 +20,12 @@ export type UseMessageSkillPluginOptions = UseMessagePlugin & { * 动态返回当前请求要使用的 skills。 */ getSkills?: (context: BasePluginContext) => MaybePromise + /** + * 执行模型为某个 skill 规划的后端命令。 + * + * @experimental 该 API 仍在设计和验证中,命令协议、返回结构和安全边界后续可能调整。 + */ + executeSkillCommand?: (request: SkillCommandRequest, context: BasePluginContext) => MaybePromise /** * skills 解析并转换为插件状态后触发。 */ @@ -30,7 +37,7 @@ const resolveSkillSource = (source: VueSkillSourceRef): VueSkillSource => { } export const skillPlugin = (options: UseMessageSkillPluginOptions): UseMessagePlugin => { - const { skills, getSkills, onSkillsResolved, ...restOptions } = options + const { skills, getSkills, executeSkillCommand, onSkillsResolved, ...restOptions } = options return { name: 'skill', @@ -42,6 +49,9 @@ export const skillPlugin = (options: UseMessageSkillPluginOptions): UseMessagePl const skillSource = getSkills ? await getSkills(vueContext) : skills return resolveSkillSource(skillSource) }, + executeSkillCommand: executeSkillCommand + ? (request, context) => executeSkillCommand(request, runtime.createVueBaseContext(context)) + : undefined, onSkillsResolved: onSkillsResolved ? (state, context) => onSkillsResolved(state, runtime.createVueBaseContext(context)) : undefined, From 2217163ba09e54be2083c25d7813e9c006b2214c Mon Sep 17 00:00:00 2001 From: gene9831 Date: Wed, 20 May 2026 10:34:25 +0800 Subject: [PATCH 6/9] feat: enhance tool plugin to include tool source tracking and improve context handling --- docs/demos/tools/skill/useSkillInspector.ts | 4 +- docs/src/tools/message.md | 28 ++- docs/src/tools/skill.md | 171 +++++++++++++++++- packages/kit/src/message/plugins/index.ts | 2 +- .../kit/src/message/plugins/toolPlugin.ts | 49 +++-- .../kit/src/message/test/toolPlugin.test.ts | 59 +++++- .../kit/src/vue/message/plugins/toolPlugin.ts | 13 +- 7 files changed, 297 insertions(+), 29 deletions(-) diff --git a/docs/demos/tools/skill/useSkillInspector.ts b/docs/demos/tools/skill/useSkillInspector.ts index a65780ade..61232d99e 100644 --- a/docs/demos/tools/skill/useSkillInspector.ts +++ b/docs/demos/tools/skill/useSkillInspector.ts @@ -1,7 +1,7 @@ import { SkillManager, compileSkillInstructions, - createSkillFileRuntimeTools, + createSkillRuntimeTools, loadSkillFilesFromFileList, } from '@opentiny/tiny-robot-kit/core' import type { SkillDefinition, SkillFile } from '@opentiny/tiny-robot-kit/core' @@ -94,7 +94,7 @@ export const useSkillInspector = () => { const inspectedDefinitionJson = computed(() => JSON.stringify(inspectedSkill.value ?? null, null, 2)) const compiledToolsJson = computed(() => { - const tools = createSkillFileRuntimeTools(selectedSkills.value).map((runtimeTool) => runtimeTool.tool) + const tools = createSkillRuntimeTools(selectedSkills.value).map((runtimeTool) => runtimeTool.tool) return JSON.stringify(tools, null, 2) }) diff --git a/docs/src/tools/message.md b/docs/src/tools/message.md index 1b4c14a4e..fd448b754 100644 --- a/docs/src/tools/message.md +++ b/docs/src/tools/message.md @@ -247,10 +247,14 @@ useMessage({ 用于接入模型返回的 `tool_calls`:在请求前注入 `tools` 列表,在请求完成后解析 `tool_calls`、执行 `callTool`、追加 tool 消息并自动发起下一轮请求。支持取消/失败时补充或标记 tool 消息、下一轮是否排除 tool 消息等。**需显式添加到 `plugins` 数组才会生效**。 +`toolPlugin` 也是 message 插件体系中的工具聚合入口。除自身的 `getTools` 外,具备工具能力的插件可以通过 `ToolProvider` 协议暴露 `provideTools(context)`,让 `toolPlugin` 在 `onBeforeRequest` 阶段统一收集并写入最终发送给模型的 `requestBody.tools`。这适合让能力型插件按自己的状态提供工具,例如 skill 文件工具、运行时工具或业务上下文相关工具。 + +工具来源会写入工具调用上下文的 `toolSource` 字段,便于在 `callTool`、`onToolCallStart`、`onToolCallEnd` 中做日志、分流或调试。`toolPlugin.getTools` 提供的工具来源为 `{ type: 'toolPlugin' }`;其他插件通过 `ToolProvider.provideTools` 提供的工具来源为 `{ type: 'toolProvider', pluginName?: string }`;无法识别来源时为 `{ type: 'unknown' }`。 + | 参数 | 类型 | 必填 | 默认值 | 说明 | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `getTools` | `() => Promise` | 是 | - | 返回当前轮次要传给 API 的工具列表(OpenAI 格式)。 | -| `callTool` | `(toolCall, context) => Promise> \| AsyncGenerator>` | 是 | - | 执行单个工具调用,返回结果字符串或可流式返回的对象,结果会合并到对应 tool 消息的 `content`。 | +| `getTools` | `() => Promise>` | 是 | - | 返回当前轮次要传给 API 的工具列表。可以返回普通 OpenAI tool schema,也可以返回带执行函数的 runtime tool。 | +| `callTool` | `(toolCall, context) => Promise> \| AsyncGenerator>` | 是 | - | 执行单个工具调用,返回结果字符串或可流式返回的对象,结果会合并到对应 tool 消息的 `content`。可通过 `context.toolSource` 判断工具来源。 | | `beforeCallTools` | `(toolCalls, context) => Promise` | 否 | - | 在真正执行工具前调用,可用于统一校验、鉴权、埋点。新字段为 `context.assistantMessage`;`context.currentMessage` 继续保留,但已弃用。 | | `onToolCallStart` | `(toolCall, context) => void` | 否 | - | 单个工具开始执行时触发。此时对应的 tool 消息已经创建并追加到 `messages` 中;`context` 额外包含 `assistantMessage`、`primaryMessage`(兼容字段)和 `toolMessage`。 | | `onToolCallEnd` | `(toolCall, context) => void` | 否 | - | 单个工具执行结束时触发。`context.status` 为 `'success' \| 'failed' \| 'cancelled'`,并额外包含 `assistantMessage`、`primaryMessage`(兼容字段)和 `toolMessage`,失败或取消时可能有 `context.error`。 | @@ -263,9 +267,20 @@ useMessage({ | 回调 | 额外上下文字段 | 说明 | | ----------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `beforeCallTools` | `assistantMessage`、`currentMessage`(已弃用) | 在 `BasePluginContext` 基础上额外包含当前这条带 `tool_calls` 的 assistant 消息。推荐使用 `assistantMessage`;`currentMessage` 为兼容旧代码保留。 | -| `callTool` | `assistantMessage`、`currentMessage`(已弃用)、`toolMessage` | 在 `BasePluginContext` 基础上额外包含当前这条带 `tool_calls` 的 assistant 消息,以及当前工具对应的 `toolMessage`。推荐使用 `assistantMessage`;`currentMessage` 为兼容旧代码保留。 | -| `onToolCallStart` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage` | 在 `BasePluginContext` 基础上额外包含触发当前工具调用的 assistant 消息和当前 tool 消息。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 | -| `onToolCallEnd` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage`、`status`、`error?` | 在 `BasePluginContext` 基础上额外包含 assistant 消息、当前 tool 消息和执行状态;当工具执行失败或被取消时,还可能包含 `error`。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 | +| `callTool` | `assistantMessage`、`currentMessage`(已弃用)、`toolMessage`、`toolSource` | 在 `BasePluginContext` 基础上额外包含当前这条带 `tool_calls` 的 assistant 消息、当前工具对应的 `toolMessage` 和工具来源。推荐使用 `assistantMessage`;`currentMessage` 为兼容旧代码保留。 | +| `onToolCallStart` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage`、`toolSource` | 在 `BasePluginContext` 基础上额外包含触发当前工具调用的 assistant 消息、当前 tool 消息和工具来源。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 | +| `onToolCallEnd` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage`、`toolSource`、`status`、`error?` | 在 `BasePluginContext` 基础上额外包含 assistant 消息、当前 tool 消息、工具来源和执行状态;当工具执行失败或被取消时,还可能包含 `error`。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 | + +`toolSource` 类型: + +```typescript +type ToolSource = + | { type: 'toolPlugin' } + | { type: 'toolProvider'; pluginName?: string } + | { type: 'unknown' } +``` + +`ToolProvider` 是供插件扩展使用的高级协议。对于使用 `toolPlugin` 的业务代码,通常只需要通过 `getTools` 和 `callTool` 接入工具;当插件本身需要按内部状态向模型暴露工具时,再实现 `provideTools(context)`。 ##### 基础示例 @@ -292,8 +307,9 @@ useMessage({ }, }, ], - callTool: async (toolCall) => { + callTool: async (toolCall, context) => { const args = JSON.parse(toolCall.function?.arguments || '{}') + console.log('Tool source:', context.toolSource) return `Weather of ${args.city}: Sunny.` }, onToolCallEnd: (toolCall, { status }) => console.log('Tool end:', status), diff --git a/docs/src/tools/skill.md b/docs/src/tools/skill.md index 0dc19426e..569e45286 100644 --- a/docs/src/tools/skill.md +++ b/docs/src/tools/skill.md @@ -27,6 +27,20 @@ interface SkillDefinition { - `instructions`:注入模型请求的核心指令,必填。 - `files`:skill 目录中的附加文件资源,可通过基础文件工具读取。 +## 完整示例 + +下面的示例把 loader、manager、compiler 放在同一条链路中展示:导入 skill 目录后,loader 解析出 `SkillDefinition`,manager 保存并选择 skill,compiler 输出最终会注入给模型的 system message 和基础文件工具。 + + + ## Loader Loader 的职责是把标准化后的 `SkillFile[]` 解析为 `SkillDefinition`。它不负责读取本地文件、浏览器文件或远程资源;这些工作由 file adapters 完成。 @@ -134,7 +148,7 @@ console.log(manager.list()) manager.remove('weather') ``` -`set(skill)` 是唯一写入入口:不存在时新增,同名存在时覆盖。 +`set(skill)` 是直接写入入口:不存在时新增,同名存在时覆盖。需要从 `SkillFile[]` 解析并写入时,使用下面的 `import(files)`。 ### 选择本次请求使用的 skills @@ -258,6 +272,8 @@ import { createSkillRuntimeTools } from '@opentiny/tiny-robot-kit/core' const runtimeTools = createSkillRuntimeTools([docsSkill]) const [listFiles, readFile] = runtimeTools +// handler 的第一个参数来自模型返回的 tool_calls。 +// 这里手动构造该参数,只是为了展示工具执行效果。 const listed = await listFiles.handler( { id: 'call_1', @@ -310,3 +326,156 @@ useMessage({ ], }) ``` + +## API + +### 核心类型 + +```typescript +type SkillFileKind = 'text' | 'binary' + +interface BaseSkillFile { + path: string + mimeType?: string + size?: number + lastModified?: number + metadata?: Record +} + +interface TextSkillFile extends BaseSkillFile { + kind: 'text' + content: string +} + +interface BinarySkillFile extends BaseSkillFile { + kind: 'binary' + content: ArrayBuffer | Uint8Array +} + +type SkillFile = TextSkillFile | BinarySkillFile + +type SkillFileResource = SkillFile & { + id: string +} + +interface SkillDefinition { + name: string + description: string + instructions: string + files?: SkillFileResource[] + metadata?: Record +} +``` + +### 文件适配器 + +浏览器安全的文件适配器从 `@opentiny/tiny-robot-kit/core` 导出: + +```typescript +function loadSkillFilesFromFileList(fileList: ArrayLike): Promise + +function loadSkillFilesFromDirectoryHandle(directoryHandle: BrowserDirectoryHandle): Promise +``` + +Node.js 文件系统适配器从 `@opentiny/tiny-robot-kit/node` 导出: + +```typescript +interface FsSkillFilesOptions { + ignoredDirectories?: string[] +} + +function loadSkillFilesFromFs(root: string, options?: FsSkillFilesOptions): Promise +``` + +### SkillLoader + +```typescript +interface SkillLoaderOptions { + entryFile?: string + strict?: boolean +} + +interface SkillLoaderResult { + skill: SkillDefinition + warnings: Array<{ + code: string + message: string + path?: string + }> +} + +class SkillLoader { + constructor(options?: SkillLoaderOptions) + load(files: SkillFile[]): SkillLoaderResult +} +``` + +### SkillManager + +```typescript +interface SkillManagerOptions { + skills?: SkillDefinition[] + selectedSkillNames?: string[] +} + +class SkillManager { + constructor(options?: SkillManagerOptions) + + set(skill: SkillDefinition): SkillDefinition + remove(name: string): SkillDefinition | undefined + clear(): void + + get(name: string): SkillDefinition | undefined + has(name: string): boolean + list(): SkillDefinition[] + + select(names: string | string[]): void + unselect(names: string | string[]): void + getSelectedSkillNames(): string[] + getSelectedSkills(): SkillDefinition[] + + import(files: SkillFile[], options?: SkillLoaderOptions): SkillLoaderResult +} +``` + +### Compiler + +```typescript +function compileSkillInstructions( + skills: SkillDefinition[], +): Promise + +function createSkillRuntimeTools(skills: SkillDefinition[]): RuntimeTool[] +``` + +`compileSkillInstructions` 会把 skill instructions 编译成 system message。`createSkillRuntimeTools` 会根据 `files` 生成基础文件 runtime tools;如果没有任何可用文件,则返回空数组。 + +### skillPlugin + +```typescript +interface SkillPluginState { + skills: SkillDefinition[] + skillNames: string[] + runtimeTools: RuntimeTool[] +} + +interface SkillPluginOptions extends MessageEnginePlugin { + getSkills?: (context: BasePluginContext) => MaybePromise + onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise +} + +function skillPlugin(options: SkillPluginOptions): MessageEnginePlugin & ToolProvider +``` + +Vue 入口还支持直接传入响应式的 `skills`: + +```typescript +type VueSkillSource = SkillDefinition[] | undefined +type VueSkillSourceRef = VueSkillSource | Ref | ComputedRef + +interface UseMessageSkillPluginOptions extends UseMessagePlugin { + skills?: VueSkillSourceRef + getSkills?: (context: BasePluginContext) => MaybePromise + onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise +} +``` diff --git a/packages/kit/src/message/plugins/index.ts b/packages/kit/src/message/plugins/index.ts index d13c83476..8fe396fd7 100644 --- a/packages/kit/src/message/plugins/index.ts +++ b/packages/kit/src/message/plugins/index.ts @@ -3,4 +3,4 @@ export { skillPlugin } from './skillPlugin' export type { SkillPluginOptions, SkillPluginState } from './skillPlugin' export { thinkingPlugin } from './thinkingPlugin' export { toolPlugin } from './toolPlugin' -export type { RuntimeTool, ToolCallContext, ToolProvider, ToolProviderItem } from './toolPlugin' +export type { RuntimeTool, ToolCallContext, ToolProvider, ToolProviderItem, ToolSource } from './toolPlugin' diff --git a/packages/kit/src/message/plugins/toolPlugin.ts b/packages/kit/src/message/plugins/toolPlugin.ts index 478e75822..230f7d882 100644 --- a/packages/kit/src/message/plugins/toolPlugin.ts +++ b/packages/kit/src/message/plugins/toolPlugin.ts @@ -13,9 +13,15 @@ type AssistantMessageWithState = ChatMessage< { toolCall?: Record> } > +export type ToolSource = { type: 'toolPlugin' } | { type: 'toolProvider'; pluginName?: string } | { type: 'unknown' } + export type ToolCallContext = BasePluginContext & { assistantMessage: AssistantMessageWithState toolMessage: ChatMessage + /** + * 当前工具的来源。 + */ + toolSource: ToolSource } type ToolCallResult = string | Record @@ -236,21 +242,36 @@ export const toolPlugin = ( } const resolveTools = async (context: BasePluginContext, existingTools: ChatCompletionFunctionTool[] = []) => { - const providedToolItems: ToolProviderItem[] = [] + const providedToolItems: Array<{ item: ToolProviderItem; source: ToolSource }> = [] for (const plugin of context.plugins) { const toolProvider = getToolProvider(plugin) if (!isPluginDisabled(plugin, context) && toolProvider) { - providedToolItems.push(...(await toolProvider.provideTools(context))) + providedToolItems.push( + ...(await toolProvider.provideTools(context)).map((item) => ({ + item, + source: { + type: 'toolProvider' as const, + pluginName: plugin.name, + }, + })), + ) } } - const toolItems = [...providedToolItems, ...(await getTools(context))] + const toolItems = [ + ...providedToolItems, + ...(await getTools(context)).map((item) => ({ + item, + source: { type: 'toolPlugin' as const }, + })), + ] const tools: ChatCompletionFunctionTool[] = [] const runtimeToolMap = new Map() + const toolSourceMap = new Map() const seenToolNames = new Set() - const trackToolName = (tool: ChatCompletionFunctionTool) => { + const registerToolName = (tool: ChatCompletionFunctionTool) => { const toolName = tool.function.name if (seenToolNames.has(toolName)) { @@ -262,12 +283,13 @@ export const toolPlugin = ( seenToolNames.add(toolName) } - existingTools.forEach(trackToolName) + existingTools.forEach(registerToolName) - for (const toolItem of toolItems) { + for (const { item: toolItem, source } of toolItems) { const tool = isRuntimeTool(toolItem) ? toolItem.tool : toolItem - trackToolName(tool) + registerToolName(tool) + toolSourceMap.set(tool.function.name, source) if (isRuntimeTool(toolItem)) { tools.push(toolItem.tool) @@ -277,7 +299,7 @@ export const toolPlugin = ( } } - return { tools, runtimeToolMap } + return { tools, runtimeToolMap, toolSourceMap } } return { @@ -326,7 +348,7 @@ export const toolPlugin = ( assistantMessage: currentMessage as AssistantMessageWithState, }) - const { runtimeToolMap } = await resolveTools(context) + const { runtimeToolMap, toolSourceMap } = await resolveTools(context) const toolCallPromises = currentMessage.tool_calls.map(async (toolCall) => { const now = Math.floor(Date.now() / 1000) @@ -343,15 +365,20 @@ export const toolPlugin = ( appendMessage(toolMessage) - const contextWithToolMessage = { + const functionToolCall = isFunctionToolCall(toolCall) ? toolCall : undefined + const toolSource = functionToolCall + ? (toolSourceMap.get(functionToolCall.function.name) ?? { type: 'unknown' as const }) + : { type: 'unknown' as const } + + const contextWithToolMessage: ToolCallContext = { ...context, assistantMessage: currentMessage as AssistantMessageWithState, toolMessage, + toolSource, } toolCallStart(toolCall, contextWithToolMessage) try { - const functionToolCall = isFunctionToolCall(toolCall) ? toolCall : undefined const runtimeTool = functionToolCall ? runtimeToolMap.get(functionToolCall.function.name) : undefined const result = runtimeTool && functionToolCall diff --git a/packages/kit/src/message/test/toolPlugin.test.ts b/packages/kit/src/message/test/toolPlugin.test.ts index 816e13285..fa73465bc 100644 --- a/packages/kit/src/message/test/toolPlugin.test.ts +++ b/packages/kit/src/message/test/toolPlugin.test.ts @@ -107,6 +107,7 @@ describe('toolPlugin', () => { }), expect.objectContaining({ toolMessage: expect.objectContaining({ role: 'tool' }), + toolSource: { type: 'toolPlugin' }, }), ) expect(fallbackCall).not.toHaveBeenCalled() @@ -196,10 +197,44 @@ describe('toolPlugin', () => { ) }) - it('loads tools provided by other plugins', async () => { - const responseProvider = vi.fn(async () => { + it('loads tools provided by other plugins and passes provider source to fallback tool calls', async () => { + const fallbackCall = vi.fn(async () => 'provider result') + const responseProvider = vi.fn(async (requestBody) => { + const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') + + if (!hasToolResult) { + expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['provided_tool']) + + return { + id: 'provider-tool-call', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-provider', + type: 'function', + function: { + name: 'provided_tool', + arguments: '{}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + } as ChatCompletion + } + return { - id: 'answer', + id: 'final-answer', object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: 'mock', @@ -217,7 +252,7 @@ describe('toolPlugin', () => { }) const providerPlugin: MessageEnginePlugin & ToolProvider = { - name: 'provider', + name: 'external-tool-provider', provideTools: async () => [ { type: 'function', @@ -235,14 +270,24 @@ describe('toolPlugin', () => { providerPlugin, toolPlugin({ getTools: async () => [], - callTool: async () => 'fallback', + callTool: fallbackCall, }), ], responseProvider, }) - await engine.sendMessage('use provided tool') + await engine.sendMessage('call provided tool') - expect(responseProvider.mock.calls[0]?.[0].tools?.map((tool) => tool.function.name)).toEqual(['provided_tool']) + expect(fallbackCall).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'call-provider', + }), + expect.objectContaining({ + toolSource: { + type: 'toolProvider', + pluginName: 'external-tool-provider', + }, + }), + ) }) }) diff --git a/packages/kit/src/vue/message/plugins/toolPlugin.ts b/packages/kit/src/vue/message/plugins/toolPlugin.ts index f3132ab09..07cfac2ec 100644 --- a/packages/kit/src/vue/message/plugins/toolPlugin.ts +++ b/packages/kit/src/vue/message/plugins/toolPlugin.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { toolPlugin as createCoreToolPlugin } from '../../../message/plugins' -import type { ToolProviderItem } from '../../../message/plugins' +import type { ToolProviderItem, ToolSource } from '../../../message/plugins' import { normalizeToAsyncGenerator } from '../../../message/utils' import { ChatMessage, ToolCall } from '../../../types' import type { VueMessagePluginRuntime } from '../types.internal' @@ -8,6 +8,10 @@ import { BasePluginContext, UseMessagePlugin } from '../types' export interface UseMessageToolActionContext extends BasePluginContext { assistantMessage: ChatMessage + /** + * 当前工具的来源。 + */ + toolSource?: ToolSource /** * @deprecated use `assistantMessage` instead */ @@ -20,6 +24,10 @@ export interface UseMessageCallToolContext extends UseMessageToolActionContext { export interface UseMessageToolCallContext extends BasePluginContext { assistantMessage: ChatMessage + /** + * 当前工具的来源。 + */ + toolSource: ToolSource /** * @deprecated use `assistantMessage` instead */ @@ -124,6 +132,7 @@ export const toolPlugin = ( assistantMessage, currentMessage: assistantMessage, toolMessage, + toolSource: context.toolSource, } as UseMessageCallToolContext, ) @@ -141,6 +150,7 @@ export const toolPlugin = ( assistantMessage, primaryMessage: assistantMessage, toolMessage, + toolSource: context.toolSource, }) } : undefined, @@ -154,6 +164,7 @@ export const toolPlugin = ( assistantMessage, primaryMessage: assistantMessage, toolMessage, + toolSource: context.toolSource, status: context.status, error: context.error, }) From ba965d2b07ab2b2c3e4ece5ef79da86a52d7b72c Mon Sep 17 00:00:00 2001 From: gene9831 Date: Wed, 20 May 2026 17:26:05 +0800 Subject: [PATCH 7/9] feat: refactor skill plugin to use SkillRequestContext and remove SkillFileResource --- AGENTS.md | 8 +++--- docs/demos/tools/skill/VueSkillPlugin.vue | 1 - docs/src/tools/skill.md | 14 ++++------ packages/kit/src/message/plugins/index.ts | 2 +- .../kit/src/message/plugins/skillPlugin.ts | 26 +++++++++---------- packages/kit/src/skills/README.md | 3 +-- packages/kit/src/skills/compiler.ts | 5 ++-- packages/kit/src/skills/index.ts | 10 +------ packages/kit/src/skills/skillLoader.ts | 14 +++------- packages/kit/src/skills/test/compiler.test.ts | 6 ----- .../kit/src/skills/test/skillLoader.test.ts | 5 ++-- .../kit/src/skills/test/skillPlugin.test.ts | 5 ++-- packages/kit/src/skills/types.ts | 9 +------ .../src/vue/message/plugins/skillPlugin.ts | 8 +++--- 14 files changed, 40 insertions(+), 76 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 043c65fa4..b250fc772 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,12 +79,12 @@ skillPlugin({ `SkillDefinition` currently contains `name`, `description`, `instructions`, optional `files`, and optional `metadata`. -Plugin state uses: +Skill request context uses: ```ts -pluginState.skills -pluginState.skillNames -pluginState.runtimeTools +skillContext.skills +skillContext.skillNames +skillContext.runtimeTools ``` ## Important Files diff --git a/docs/demos/tools/skill/VueSkillPlugin.vue b/docs/demos/tools/skill/VueSkillPlugin.vue index 90068b9e0..5c6641aec 100644 --- a/docs/demos/tools/skill/VueSkillPlugin.vue +++ b/docs/demos/tools/skill/VueSkillPlugin.vue @@ -63,7 +63,6 @@ const allSkills: SkillDefinition[] = [ instructions: 'Use weather references when the user asks about weather. Keep the answer concise.', files: [ { - id: 'references/weather-format.md', path: 'references/weather-format.md', kind: 'text', content: 'Return current condition first, then one short forecast point.', diff --git a/docs/src/tools/skill.md b/docs/src/tools/skill.md index 569e45286..b6655df06 100644 --- a/docs/src/tools/skill.md +++ b/docs/src/tools/skill.md @@ -17,7 +17,7 @@ interface SkillDefinition { name: string description: string instructions: string - files?: SkillFileResource[] + files?: SkillFile[] metadata?: Record } ``` @@ -354,15 +354,11 @@ interface BinarySkillFile extends BaseSkillFile { type SkillFile = TextSkillFile | BinarySkillFile -type SkillFileResource = SkillFile & { - id: string -} - interface SkillDefinition { name: string description: string instructions: string - files?: SkillFileResource[] + files?: SkillFile[] metadata?: Record } ``` @@ -453,7 +449,7 @@ function createSkillRuntimeTools(skills: SkillDefinition[]): RuntimeTool[] ### skillPlugin ```typescript -interface SkillPluginState { +interface SkillRequestContext { skills: SkillDefinition[] skillNames: string[] runtimeTools: RuntimeTool[] @@ -461,7 +457,7 @@ interface SkillPluginState { interface SkillPluginOptions extends MessageEnginePlugin { getSkills?: (context: BasePluginContext) => MaybePromise - onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise } function skillPlugin(options: SkillPluginOptions): MessageEnginePlugin & ToolProvider @@ -476,6 +472,6 @@ type VueSkillSourceRef = VueSkillSource | Ref | ComputedRef MaybePromise - onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise } ``` diff --git a/packages/kit/src/message/plugins/index.ts b/packages/kit/src/message/plugins/index.ts index 8fe396fd7..081a9120a 100644 --- a/packages/kit/src/message/plugins/index.ts +++ b/packages/kit/src/message/plugins/index.ts @@ -1,6 +1,6 @@ export { lengthPlugin } from './lengthPlugin' export { skillPlugin } from './skillPlugin' -export type { SkillPluginOptions, SkillPluginState } from './skillPlugin' +export type { SkillPluginOptions, SkillRequestContext } from './skillPlugin' export { thinkingPlugin } from './thinkingPlugin' export { toolPlugin } from './toolPlugin' export type { RuntimeTool, ToolCallContext, ToolProvider, ToolProviderItem, ToolSource } from './toolPlugin' diff --git a/packages/kit/src/message/plugins/skillPlugin.ts b/packages/kit/src/message/plugins/skillPlugin.ts index 0062814d7..de3fe35ce 100644 --- a/packages/kit/src/message/plugins/skillPlugin.ts +++ b/packages/kit/src/message/plugins/skillPlugin.ts @@ -6,11 +6,11 @@ import type { BasePluginContext, MessageEnginePlugin } from '../types' import type { RuntimeTool, ToolProvider } from './toolPlugin' /** - * Skill 插件的转换状态。 + * 当前请求的 skill 上下文。 * - * 该状态会写入 customContext.__tiny_robot_skill,供消息钩子和插件回调读取同一份编译结果。 + * 该上下文会写入 customContext.__tiny_robot_skill,供消息钩子和插件回调读取同一份请求级数据。 */ -export interface SkillPluginState { +export interface SkillRequestContext { skills: SkillDefinition[] skillNames: string[] runtimeTools: RuntimeTool[] @@ -35,9 +35,9 @@ export type SkillPluginOptions = MessageEnginePlugin & { */ executeSkillCommand?: (request: SkillCommandRequest, context: BasePluginContext) => MaybePromise /** - * skills 解析并规整为插件状态后触发。 + * skills 解析并规整为请求上下文后触发。 */ - onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise } const skillPluginContextKey = '__tiny_robot_skill' @@ -49,12 +49,12 @@ export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin & name: 'skill', ...restOptions, provideTools: async (context: BasePluginContext) => { - const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined - return state?.runtimeTools ?? [] + const skillContext = context.customContext[skillPluginContextKey] as SkillRequestContext | undefined + return skillContext?.runtimeTools ?? [] }, onTurnStart: async (context) => { const skills = (await getSkills?.(context)) ?? [] - const state: SkillPluginState = { + const skillContext: SkillRequestContext = { skills, skillNames: skills.map((skill) => skill.name), runtimeTools: createSkillRuntimeTools(skills, { @@ -62,16 +62,16 @@ export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin & }), } - context.setCustomContext({ [skillPluginContextKey]: state }) + context.setCustomContext({ [skillPluginContextKey]: skillContext }) - await onSkillsResolved?.(state, context) + await onSkillsResolved?.(skillContext, context) return restOptions.onTurnStart?.(context) }, onBeforeRequest: async (context) => { - const state = context.customContext[skillPluginContextKey] as SkillPluginState | undefined + const skillContext = context.customContext[skillPluginContextKey] as SkillRequestContext | undefined - if (state) { - const skillInstructions = await compileSkillInstructions(state.skills) + if (skillContext) { + const skillInstructions = await compileSkillInstructions(skillContext.skills) if (skillInstructions) { context.requestBody.messages = [skillInstructions, ...context.requestBody.messages] } diff --git a/packages/kit/src/skills/README.md b/packages/kit/src/skills/README.md index 88bd6e488..c84cbee36 100644 --- a/packages/kit/src/skills/README.md +++ b/packages/kit/src/skills/README.md @@ -17,7 +17,7 @@ interface SkillDefinition { name: string description: string instructions: string - files?: SkillFileResource[] + files?: SkillFile[] metadata?: Record } ``` @@ -36,7 +36,6 @@ interface SkillDefinition { - `SkillDefinition` - `SkillFile` -- `SkillFileResource` - 文本和二进制 skill 文件类型 该文件不包含运行逻辑。 diff --git a/packages/kit/src/skills/compiler.ts b/packages/kit/src/skills/compiler.ts index 388cec2c3..026b4b7ea 100644 --- a/packages/kit/src/skills/compiler.ts +++ b/packages/kit/src/skills/compiler.ts @@ -1,7 +1,7 @@ import type { ChatCompletionSystemMessageParam } from 'openai/resources' import type { RuntimeTool } from '../message/plugins/toolPlugin' import type { MaybePromise } from '../types' -import type { SkillDefinition, SkillFileResource } from './types' +import type { SkillDefinition, SkillFile } from './types' const skillFileToolNames = { listSkillFiles: 'list_skill_files', @@ -100,9 +100,8 @@ const skillCommandTool: RuntimeTool['tool'] = { }, } -const getSkillFileSummary = (skillName: string, file: SkillFileResource) => ({ +const getSkillFileSummary = (skillName: string, file: SkillFile) => ({ skillName, - id: file.id, path: file.path, kind: file.kind, mimeType: file.mimeType, diff --git a/packages/kit/src/skills/index.ts b/packages/kit/src/skills/index.ts index ff71c81a2..55e363bb0 100644 --- a/packages/kit/src/skills/index.ts +++ b/packages/kit/src/skills/index.ts @@ -11,13 +11,5 @@ export { SkillManager } from './manager' export type { SkillManagerOptions } from './manager' export { SkillLoader } from './skillLoader' export type { SkillLoaderOptions, SkillLoaderResult } from './skillLoader' -export type { - BaseSkillFile, - BinarySkillFile, - SkillDefinition, - SkillFile, - SkillFileKind, - SkillFileResource, - TextSkillFile, -} from './types' +export type { BaseSkillFile, BinarySkillFile, SkillDefinition, SkillFile, SkillFileKind, TextSkillFile } from './types' export { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' diff --git a/packages/kit/src/skills/skillLoader.ts b/packages/kit/src/skills/skillLoader.ts index a4ef3c334..d7170323e 100644 --- a/packages/kit/src/skills/skillLoader.ts +++ b/packages/kit/src/skills/skillLoader.ts @@ -1,5 +1,5 @@ import { parse as parseYaml } from 'yaml' -import type { SkillDefinition, SkillFile, SkillFileResource } from './types' +import type { SkillDefinition, SkillFile } from './types' import { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' export interface SkillLoaderResult { @@ -72,7 +72,7 @@ export class SkillLoader { } const frontmatterMetadata = getRecord(frontmatter.metadata) - const skillFiles: SkillFileResource[] = [] + const skillFiles: SkillFile[] = [] for (const file of normalizedFiles) { if (file.path === this.entryFile) { @@ -80,10 +80,7 @@ export class SkillLoader { } if (file.kind === 'binary') { - skillFiles.push({ - ...file, - id: file.path, - }) + skillFiles.push(file) continue } @@ -96,10 +93,7 @@ export class SkillLoader { continue } - skillFiles.push({ - ...file, - id: file.path, - }) + skillFiles.push(file) } return { diff --git a/packages/kit/src/skills/test/compiler.test.ts b/packages/kit/src/skills/test/compiler.test.ts index 6c524ee62..0099a5af4 100644 --- a/packages/kit/src/skills/test/compiler.test.ts +++ b/packages/kit/src/skills/test/compiler.test.ts @@ -10,7 +10,6 @@ describe('skill compiler', () => { instructions: 'Use docs.', files: [ { - id: 'guide.md', path: 'guide.md', kind: 'text', content: '# Guide', @@ -76,14 +75,12 @@ describe('skill compiler', () => { instructions: 'Use docs.', files: [ { - id: 'guide.md', path: 'guide.md', kind: 'text', content: '# Guide', mimeType: 'text/markdown', }, { - id: 'icon.png', path: 'icon.png', kind: 'binary', content: new Uint8Array([1, 2, 3]), @@ -138,7 +135,6 @@ describe('skill compiler', () => { instructions: 'Use docs.', files: [ { - id: 'guide.md', path: 'guide.md', kind: 'text', content: '# Guide', @@ -151,7 +147,6 @@ describe('skill compiler', () => { instructions: 'Use Vue.', files: [ { - id: 'sfc.md', path: 'sfc.md', kind: 'text', content: '# SFC', @@ -178,7 +173,6 @@ describe('skill compiler', () => { instructions: 'Use docs.', files: [ { - id: 'guide.md', path: 'guide.md', kind: 'text', content: '# Guide', diff --git a/packages/kit/src/skills/test/skillLoader.test.ts b/packages/kit/src/skills/test/skillLoader.test.ts index 74f217aca..ce083deb0 100644 --- a/packages/kit/src/skills/test/skillLoader.test.ts +++ b/packages/kit/src/skills/test/skillLoader.test.ts @@ -32,7 +32,7 @@ describe('SkillLoader', () => { }) expect(skill.files).toBeDefined() expect(skill.files).toHaveLength(files.length - 1) - expect(skill.files?.map((file) => file.id)).toEqual( + expect(skill.files?.map((file) => file.path)).toEqual( expect.arrayContaining([ 'references/reactivity.md', 'references/sfc.md', @@ -40,7 +40,7 @@ describe('SkillLoader', () => { 'references/composables.md', ]), ) - expect(skill.files?.find((file) => file.id === 'references/reactivity.md')).toMatchObject({ + expect(skill.files?.find((file) => file.path === 'references/reactivity.md')).toMatchObject({ path: 'references/reactivity.md', kind: 'text', content: expect.stringContaining('# Reactivity'), @@ -75,7 +75,6 @@ describe('SkillLoader', () => { expect(loadedSkill.skill.files).toEqual([ { - id: 'assets/icon.png', path: 'assets/icon.png', kind: 'binary', content: image, diff --git a/packages/kit/src/skills/test/skillPlugin.test.ts b/packages/kit/src/skills/test/skillPlugin.test.ts index 10c82fba5..936a48f2a 100644 --- a/packages/kit/src/skills/test/skillPlugin.test.ts +++ b/packages/kit/src/skills/test/skillPlugin.test.ts @@ -113,7 +113,6 @@ describe('skillPlugin', () => { instructions: 'Follow Vue best practices.', files: [ { - id: 'references/reactivity.md', path: 'references/reactivity.md', kind: 'text', content: '# Reactivity', @@ -298,8 +297,8 @@ describe('skillPlugin', () => { ...silentDefaultPlugins, skillPlugin({ getSkills: () => [weatherSkill], - onSkillsResolved: (state) => { - resolvedState(state.skillNames) + onSkillsResolved: (skillContext) => { + resolvedState(skillContext.skillNames) }, onTurnStart: (context) => { turnStart(context.customContext.__tiny_robot_skill) diff --git a/packages/kit/src/skills/types.ts b/packages/kit/src/skills/types.ts index 337c3d4e6..1075464a5 100644 --- a/packages/kit/src/skills/types.ts +++ b/packages/kit/src/skills/types.ts @@ -38,13 +38,6 @@ export interface BinarySkillFile extends BaseSkillFile { export type SkillFile = TextSkillFile | BinarySkillFile -export type SkillFileResource = SkillFile & { - /** - * 所属 skill 内唯一的文件标识。 - */ - id: string -} - /** * skill 能力模板。 * @@ -66,7 +59,7 @@ export interface SkillDefinition { /** * 可供 skill 文件运行时工具读取的文件。 */ - files?: SkillFileResource[] + files?: SkillFile[] /** * 应用侧自定义元数据。 */ diff --git a/packages/kit/src/vue/message/plugins/skillPlugin.ts b/packages/kit/src/vue/message/plugins/skillPlugin.ts index 522b70ced..accd9f0a1 100644 --- a/packages/kit/src/vue/message/plugins/skillPlugin.ts +++ b/packages/kit/src/vue/message/plugins/skillPlugin.ts @@ -1,6 +1,6 @@ import type { ComputedRef, Ref } from 'vue' import { isRef, unref } from 'vue' -import type { SkillPluginState } from '../../../message/plugins' +import type { SkillRequestContext } from '../../../message/plugins' import { skillPlugin as createCoreSkillPlugin } from '../../../message/plugins' import type { SkillCommandRequest, SkillCommandResult } from '../../../skills/compiler' import type { SkillDefinition } from '../../../skills/types' @@ -27,9 +27,9 @@ export type UseMessageSkillPluginOptions = UseMessagePlugin & { */ executeSkillCommand?: (request: SkillCommandRequest, context: BasePluginContext) => MaybePromise /** - * skills 解析并转换为插件状态后触发。 + * skills 解析并转换为请求上下文后触发。 */ - onSkillsResolved?: (state: SkillPluginState, context: BasePluginContext) => MaybePromise + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise } const resolveSkillSource = (source: VueSkillSourceRef): VueSkillSource => { @@ -53,7 +53,7 @@ export const skillPlugin = (options: UseMessageSkillPluginOptions): UseMessagePl ? (request, context) => executeSkillCommand(request, runtime.createVueBaseContext(context)) : undefined, onSkillsResolved: onSkillsResolved - ? (state, context) => onSkillsResolved(state, runtime.createVueBaseContext(context)) + ? (skillContext, context) => onSkillsResolved(skillContext, runtime.createVueBaseContext(context)) : undefined, }) }, From a4d53e4a3fdde140b2e820078699d1d02084c1b1 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Thu, 21 May 2026 10:25:25 +0800 Subject: [PATCH 8/9] fix: update build script to increase memory limit and enhance skill.md documentation --- docs/package.json | 2 +- docs/src/tools/skill.md | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index 25985e012..3e72eec86 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "dev": "cross-env VP_MODE=development vitepress dev", - "build": "cross-env VP_MODE=production vitepress build", + "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 VP_MODE=production vitepress build", "preview": "vitepress preview" }, "devDependencies": { diff --git a/docs/src/tools/skill.md b/docs/src/tools/skill.md index b6655df06..1ccce784d 100644 --- a/docs/src/tools/skill.md +++ b/docs/src/tools/skill.md @@ -10,6 +10,16 @@ Skill 是一组可复用的能力模板。一个 skill 至少包含名称、描 - **Loader / Manager**:把 `SkillFile[]` 解析为 `SkillDefinition`,并管理 skill 集合与选择状态。 - **Compiler**:把已选 `SkillDefinition[]` 编译为 message engine 可消费的 instructions 和运行时文件工具。 +`SkillManager` 是可选中间层。如果业务侧已经有自己的状态管理,可以直接把 `SkillDefinition[]` 交给 compiler 或 `skillPlugin`。 + +```text +File Adapter -> Loader -------> SkillDefinition[] -> Compiler -> message engine + | ^ + | optional | + v | + SkillManager -----------+ +``` + ## 基本数据模型 ```typescript From e2a4e43fd9b42672069ecab4229eded31a598403 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Thu, 21 May 2026 15:30:02 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20README.md?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=90=9C=E7=B4=A2=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E3=80=81=E6=B6=88=E6=81=AF=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=92=8C=E6=96=87=E4=BB=B6=E5=AD=98=E5=82=A8=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/kit/src/skills/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/kit/src/skills/README.md b/packages/kit/src/skills/README.md index c84cbee36..bf1379d13 100644 --- a/packages/kit/src/skills/README.md +++ b/packages/kit/src/skills/README.md @@ -353,3 +353,12 @@ selectionStatus: 'pending' | 'done' - 为 `read_skill_file` 增加大小限制和截断策略。 - 为重复 skill 名称增加诊断能力,优先放在 manager 或选择逻辑中。 - 评估 auto skill selection 是否需要独立 selector 层。 +- search text tool +- 消息模型 + system skill name + description, prompt提示当前环境 + user message + llm select,直接获取skill file +- mcp沙盒 +- 手动@选择一个skill,system prompt提示优先使用当前skill +- 文件存储 storageStrategy +- 文档描述优化