free-code-dotnet/docs/UI与扩展设计/UI与扩展设计-技能系统.md
应文浩wenhao.ying@xiaobao100.com e25ac591a7 init easy-code
2026-04-06 07:24:24 +08:00

8.5 KiB
Raw Blame History

技能系统

所属项目: free-code .NET 10 重写 所属模块: UI 与扩展设计 原始代码: ../../src/skills/


概述

技能系统允许用户以 Markdown 文件的形式定义可复用的指令集,通过斜杠命令或提示注入的方式触发。原始实现扫描 .free-code/skills/ 目录,解析每个文件的 YAML frontmatter 获取元数据,并将 Markdown 正文作为 System Prompt 内容注入查询引擎。

.NET 重写通过 ISkillLoader 接口封装这一流程,用 SkillDefinition record 描述单条技能,用 SkillHooks record 描述生命周期钩子。SkillLoader 是默认实现负责目录扫描、YAML 解析、结果缓存和执行委托。


16.1 接口与数据模型

/// <summary>
/// 技能加载器 — 从 .free-code/skills/ 目录加载 SKILL.md
/// 对应原始 skill system 的目录扫描 + frontmatter 解析
/// </summary>
public interface ISkillLoader
{
    Task<IReadOnlyList<SkillDefinition>> LoadAllSkillsAsync();
    Task<SkillDefinition?> LoadSkillAsync(string skillName);
    Task ExecuteSkillAsync(SkillDefinition skill, string? args);
}

SkillDefinition

/// <summary>
/// 技能定义 — 对应一个 SKILL.md 文件的完整解析结果
/// </summary>
public sealed record SkillDefinition
{
    /// <summary>技能名称,来自 frontmatter 或文件名</summary>
    public required string Name { get; init; }

    /// <summary>技能描述,用于斜杠命令帮助文本</summary>
    public string? Description { get; init; }

    /// <summary>Markdown 正文,作为 System Prompt 注入</summary>
    public required string Content { get; init; }

    /// <summary>技能允许使用的工具名称列表</summary>
    public IReadOnlyList<string> Tools { get; init; } = [];

    /// <summary>覆盖默认模型(如 "claude-haiku-4-5"</summary>
    public string? Model { get; init; }

    /// <summary>技能参数定义(参数名 → 描述)</summary>
    public IReadOnlyDictionary<string, string>? Arguments { get; init; }

    /// <summary>生命周期钩子</summary>
    public SkillHooks? Hooks { get; init; }

    /// <summary>磁盘路径,仅供调试</summary>
    public string? FilePath { get; init; }
}

SkillHooks

/// <summary>
/// 技能生命周期钩子
/// 对应原始 skill hooks 机制
/// </summary>
public sealed record SkillHooks
{
    /// <summary>执行前的提示注入片段</summary>
    public string? PreExecute { get; init; }

    /// <summary>执行后的提示注入片段</summary>
    public string? PostExecute { get; init; }

    /// <summary>发生错误时的提示注入片段</summary>
    public string? OnError { get; init; }
}

设计意图

SkillDefinition 使用 sealed record 确保不可变性:一旦从磁盘解析完成,技能定义在整个会话生命周期内不会改变。Tools 字段对应原始 frontmatter 中的 tools 数组,允许技能声明自己需要哪些工具,ToolRegistry 在组装工具池时可据此过滤。


16.2 SkillLoader 实现

public sealed class SkillLoader : ISkillLoader
{
    private readonly string[] _skillDirectories;
    private List<SkillDefinition>? _cached;
    private readonly ILogger<SkillLoader> _logger;

    public SkillLoader(ILogger<SkillLoader> logger)
    {
        _logger = logger;
        var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        var cwd  = Directory.GetCurrentDirectory();

        // 扫描顺序: 项目级 → 用户级
        _skillDirectories =
        [
            Path.Combine(cwd,  ".free-code", "skills"),
            Path.Combine(home, ".free-code", "skills"),
        ];
    }

    public async Task<IReadOnlyList<SkillDefinition>> LoadAllSkillsAsync()
    {
        if (_cached != null) return _cached;
        _cached = [];

        foreach (var dir in _skillDirectories)
        {
            if (!Directory.Exists(dir)) continue;
            foreach (var file in Directory.GetFiles(dir, "*.md", SearchOption.AllDirectories))
            {
                try
                {
                    var skill = await ParseSkillFileAsync(file);
                    if (skill != null) _cached.Add(skill);
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "Failed to load skill from {File}", file);
                }
            }
        }
        return _cached;
    }

    public async Task<SkillDefinition?> LoadSkillAsync(string skillName)
    {
        var all = await LoadAllSkillsAsync();
        return all.FirstOrDefault(s =>
            string.Equals(s.Name, skillName, StringComparison.OrdinalIgnoreCase));
    }

    /// <summary>
    /// 解析 SKILL.md — YAML frontmatter + Markdown body
    /// </summary>
    private static async Task<SkillDefinition?> ParseSkillFileAsync(string filePath)
    {
        var content = await File.ReadAllTextAsync(filePath);
        if (!content.StartsWith("---")) return null;

        var endIdx = content.IndexOf("\n---", 3);
        if (endIdx < 0) return null;

        var frontmatter = content[3..endIdx];
        var body        = content[(endIdx + 4)..].TrimStart();

        // 解析 YAML frontmatter
        var deserializer = new YamlDotNet.Serialization.DeserializerBuilder().Build();
        var meta = deserializer.Deserialize<Dictionary<string, object>>(frontmatter);

        return new SkillDefinition
        {
            Name = meta.GetValueOrDefault("name",
                Path.GetFileNameWithoutExtension(filePath))?.ToString() ?? "",
            Description = meta.GetValueOrDefault("description")?.ToString(),
            Content = body,
            Tools = (meta.GetValueOrDefault("tools") as List<object>)
                ?.Select(o => o.ToString() ?? "")
                .ToList() ?? [],
            Model    = meta.GetValueOrDefault("model")?.ToString(),
            FilePath = filePath,
        };
    }

    public async Task ExecuteSkillAsync(SkillDefinition skill, string? args)
    {
        // 技能内容注入 System Prompt参数拼接到末尾由 QueryEngine 执行
        var fullPrompt = skill.Content;
        if (args != null)
            fullPrompt += $"\n\nArguments: {args}";

        // 通过 QueryEngine 提交,渲染到 UI
        var engine = /* 从 IServiceProvider 解析 */;
        await foreach (var msg in engine.SubmitMessageAsync(fullPrompt))
        {
            // UI 渲染逻辑
        }
    }
}

目录扫描与优先级

SkillLoader 按以下顺序扫描两个目录,先找到的技能名称优先:

优先级 路径 说明
1 <当前工作目录>/.free-code/skills/ 项目级技能,随代码库版本管理
2 ~/.free-code/skills/ 用户级技能,跨项目共享

同名技能以项目级为准,允许项目级覆盖用户级的默认行为。


SKILL.md 文件格式

---
name: code-review
description: 对当前分支的变更进行代码审查
tools:
  - Bash
  - Read
model: claude-sonnet-4-6
---

你是一位严谨的代码审查员。分析提供的代码变更,重点关注:
- 潜在的 bug 和边界条件
- 安全漏洞
- 性能问题
- 代码风格与项目规范的一致性

输出格式:按严重性分组,每条问题标注文件和行号。

frontmatter 字段说明:

字段 类型 必填 说明
name string 技能名称,默认使用文件名
description string 显示在斜杠命令帮助中的描述
tools string[] 允许使用的工具,空则使用全部
model string 覆盖默认模型
arguments object 参数定义(名称 → 描述)

生命周期钩子执行顺序

ExecuteSkillAsync(skill, args)
    │
    ├── 1. hooks.PreExecute  → 注入到提示前缀
    ├── 2. skill.Content     → 主提示内容
    ├── 3. args              → 用户传入参数
    │
    └── QueryEngine.SubmitMessageAsync(fullPrompt)
            │
            └── [成功] hooks.PostExecute
            └── [失败] hooks.OnError

参考资料