266 lines
8.5 KiB
Markdown
266 lines
8.5 KiB
Markdown
# 技能系统
|
||
|
||
> 所属项目: free-code .NET 10 重写
|
||
> 所属模块: [UI 与扩展设计](UI与扩展设计.md)
|
||
> 原始代码: `../../src/skills/`
|
||
|
||
---
|
||
|
||
## 概述
|
||
|
||
技能系统允许用户以 Markdown 文件的形式定义可复用的指令集,通过斜杠命令或提示注入的方式触发。原始实现扫描 `.free-code/skills/` 目录,解析每个文件的 YAML frontmatter 获取元数据,并将 Markdown 正文作为 System Prompt 内容注入查询引擎。
|
||
|
||
.NET 重写通过 `ISkillLoader` 接口封装这一流程,用 `SkillDefinition` record 描述单条技能,用 `SkillHooks` record 描述生命周期钩子。`SkillLoader` 是默认实现,负责目录扫描、YAML 解析、结果缓存和执行委托。
|
||
|
||
---
|
||
|
||
## 16.1 接口与数据模型
|
||
|
||
```csharp
|
||
/// <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
|
||
|
||
```csharp
|
||
/// <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
|
||
|
||
```csharp
|
||
/// <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 实现
|
||
|
||
```csharp
|
||
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 文件格式
|
||
|
||
```markdown
|
||
---
|
||
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
|
||
```
|
||
|
||
---
|
||
|
||
## 参考资料
|
||
|
||
- [UI 与扩展设计 — 总览](UI与扩展设计.md)
|
||
- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
|
||
- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md)
|
||
- [UI 与扩展设计 — 插件系统](UI与扩展设计-插件系统.md)
|