8.5 KiB
8.5 KiB
技能系统
所属项目: 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