Files
DevFlow/src-tauri/src/commands/ai/skills.rs
T
lxy bd6a41fe6e 新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
2026-06-21 20:51:26 +08:00

388 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 本机 Claude 技能扫描(skills / commands / plugins 三类)
//!
//! 核心设计6(/ skill 修复):
//! - `strip_frontmatter` + `read_skill_content_stripped`:注入正文剥首个 `---...---` 块
//! (原 `read_skill_content` 保留兼容,返全文)
//! - `OnceLock<Vec>` → `RwLock<Option<Vec>>` + 快路径(读锁命中 clone)/ 慢路径(重扫)
//! + `invalidate_skills` 写锁置 None,支持进程内 `ai_reload_skills` 热重载
//! - `scan_skills` 返 `ScanResult{skills, conflicts}`:同名收集所有 path 入 conflicts,
//! 保留首份(优先级 skills>commands>plugins);`SkillInfo.duplicates` 仅冲突时填。
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{RwLock, RwLockReadGuard};
use serde::Serialize;
/// 技能元信息(前端 `/` 联想 + 后端注入用)
#[derive(Debug, Clone, Serialize)]
pub struct SkillInfo {
pub name: String,
pub description: String,
pub argument_hint: Option<String>,
/// skill | command | plugin
pub source: String,
/// SKILL.md 绝对路径(注入时读全文)
pub path: String,
/// 同名冲突时的其它来源路径(仅冲突时填 Some;首份为 None)。
///
/// 注入始终取首份(优先级 skills>commands>plugins),前端据非空 duplicates 提示用户
/// "存在同名技能 N 份,已用 {source} 来源"。无冲突为 None,向后兼容旧前端(字段缺省)。
#[serde(skip_serializing_if = "Option::is_none")]
pub duplicates: Option<Vec<String>>,
}
/// 扫描结果:去重后的 skills 列表 + 同名冲突明细(前端提示用)。
///
/// `conflicts`: `(name, 所有同名的 path 列表)` —— 注入取 skills[0](即 ScanResult.skills 中
/// 首份,优先级 skills>commands>plugins),冲突列表仅做提示,不影响注入。
#[derive(Debug, Clone, Serialize)]
pub struct ScanResult {
pub skills: Vec<SkillInfo>,
pub conflicts: Vec<(String, Vec<String>)>,
}
/// ~/.claude 目录(跨平台:USERPROFILE / HOME
fn claude_home() -> Option<PathBuf> {
std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.map(|h| h.join(".claude"))
}
/// 剥离 markdown 首个 `---...---` frontmatter 块,返回正文。
///
/// 状态机:用行索引推进;首个 `---`(trim 后)进入 in_fm,遇到下个 `---` 退出,余为正文。
/// 无 frontmatter(首行非 ---)直接返原文;frontmatter 未闭合(只有起始 --- 无收尾)
/// 按容错返空串(避免把整篇当 frontmatter 误剥,此分支极罕见 —— SKILL.md 都闭合)。
///
/// 注入用:原 `read_skill_content` 返全文(含 frontmatter)会让 LLM 看到 YAML 头噪声,
/// 改用 `read_skill_content_stripped` 后注入正文干净。
pub(crate) fn strip_frontmatter(md: &str) -> String {
let mut lines = md.lines().enumerate().peekable();
// 空串 / 首行非 --- → 无 frontmatter,返原文
let first = match lines.next() {
Some((_, l)) => l,
None => return String::new(),
};
if first.trim() != "---" {
// 无 frontmatter:返原文(首行 + 余下,用 \n 拼回)
let mut out = first.to_string();
for (_, l) in lines {
out.push('\n');
out.push_str(l);
}
return out;
}
// in_fm:跳过直到下个 ---
let mut body_start: Option<usize> = None;
for (i, l) in lines {
if l.trim() == "---" {
body_start = Some(i + 1);
break;
}
}
match body_start {
Some(start) => {
// 正文 = 原文第 start 行(0-based)起所有行
md.lines().skip(start).collect::<Vec<_>>().join("\n")
}
None => String::new(), // frontmatter 未闭合
}
}
/// 剥离 YAML 标量值两侧的引号(`"..."` / `'...'`),简易 frontmatter 解析用
fn unquote(s: &str) -> &str {
s.strip_prefix('"')
.and_then(|x| x.strip_suffix('"'))
.or_else(|| s.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')))
.unwrap_or(s)
}
/// 解析 markdown frontmatter 的 name / description / argument-hint / user_invocable
/// (简易,按行匹配,容错缩进与 CRLF;仅扫描 frontmatter 区段)
fn parse_frontmatter(md: &str) -> Option<(String, String, Option<String>, bool)> {
let mut lines = md.lines();
if lines.next()?.trim() != "---" {
return None;
}
let mut name = None;
let mut desc = None;
let mut hint = None;
let mut invocable = true;
for line in lines {
if line.trim() == "---" {
break;
}
let l = line.trim_start();
if let Some(v) = l.strip_prefix("name:") {
name = Some(unquote(v.trim()).to_string());
} else if let Some(v) = l.strip_prefix("description:") {
desc = Some(unquote(v.trim()).to_string());
} else if let Some(v) = l.strip_prefix("argument-hint:") {
hint = Some(unquote(v.trim()).to_string());
} else if let Some(v) = l.strip_prefix("user_invocable:") {
invocable = v.trim() != "false";
}
}
name.map(|n| (n, desc.unwrap_or_default(), hint, invocable))
}
/// 解析单个 SKILL.md / command md 为 SkillInfo(排除 user_invocable: false
fn parse_skill_file(path: &Path, source: &str) -> Option<SkillInfo> {
let md = fs::read_to_string(path).ok()?;
let (name, description, argument_hint, invocable) = parse_frontmatter(&md).unwrap_or_else(|| {
// 无 frontmatter(部分 commands):用文件名兜底,默认可调用
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
(stem, String::new(), None, true)
});
if !invocable {
return None;
}
Some(SkillInfo {
name,
description,
argument_hint,
source: source.to_string(),
path: path.to_string_lossy().to_string(),
duplicates: None,
})
}
/// 递归收集目录下所有 SKILL.md(用于 plugins/marketplaces 多层嵌套)
fn collect_skill_files(dir: &Path, out: &mut Vec<PathBuf>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
// 跳过依赖/版本目录,避免递归爆炸
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "node_modules" || name == ".git" {
continue;
}
collect_skill_files(&p, out);
} else if p.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
out.push(p);
}
}
}
}
/// 扫描三类来源,按 name 去重(skills 优先 > commands > plugins)。
///
/// 返 `ScanResult``skills` 为去重后首份(优先级保留),`conflicts` 收集所有同名 path
/// (仅当同名 > 1 时入列,前端提示用)。
fn scan_skills() -> ScanResult {
let home = match claude_home() {
Some(h) => h,
None => {
return ScanResult {
skills: Vec::new(),
conflicts: Vec::new(),
}
}
};
// 三类来源按优先级顺序收集(skills > commands > plugins
let mut all: Vec<Vec<SkillInfo>> = Vec::with_capacity(3);
// 1. ~/.claude/skills/*/SKILL.md
let mut batch_skill = Vec::new();
if let Ok(entries) = fs::read_dir(home.join("skills")) {
for entry in entries.flatten() {
if let Some(info) = parse_skill_file(&entry.path().join("SKILL.md"), "skill") {
batch_skill.push(info);
}
}
}
all.push(batch_skill);
// 2. ~/.claude/commands/*.md
let mut batch_cmd = Vec::new();
if let Ok(entries) = fs::read_dir(home.join("commands")) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("md") {
if let Some(info) = parse_skill_file(&p, "command") {
batch_cmd.push(info);
}
}
}
}
all.push(batch_cmd);
// 3. ~/.claude/plugins/marketplaces/**/skills/*/SKILL.md(递归;cache 不在此路径下)
let mut files = Vec::new();
collect_skill_files(&home.join("plugins").join("marketplaces"), &mut files);
let mut batch_plugin = Vec::new();
for f in files {
if let Some(info) = parse_skill_file(&f, "plugin") {
batch_plugin.push(info);
}
}
all.push(batch_plugin);
// 按 name 去重(首份优先级保留)+ 收集冲突
// name -> (首份 index in skills, 所有 path)
let mut seen: HashSet<String> = HashSet::new();
let mut skills: Vec<SkillInfo> = Vec::new();
// name -> Vec<path>(按来源顺序,用于冲突判定 + duplicates 回填)
let mut name_paths: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for batch in &all {
for info in batch {
name_paths
.entry(info.name.clone())
.or_default()
.push(info.path.clone());
if seen.insert(info.name.clone()) {
skills.push(info.clone());
}
}
}
// 回填 duplicates + 构造 conflicts
let mut conflicts: Vec<(String, Vec<String>)> = Vec::new();
for skill in skills.iter_mut() {
if let Some(paths) = name_paths.get(&skill.name) {
if paths.len() > 1 {
// 首份 path 是 skill.path 本身,duplicates 填其余(按收集顺序)
let dups: Vec<String> = paths
.iter()
.filter(|p| **p != skill.path)
.cloned()
.collect();
if !dups.is_empty() {
skill.duplicates = Some(dups);
}
conflicts.push((skill.name.clone(), paths.clone()));
}
}
}
ScanResult { skills, conflicts }
}
/// 进程内技能缓存(RwLock + Option 懒初始化)。
///
/// 设计(核心设计6:
/// - 快路径:`skills_cached()` 读锁命中 → clone 返回(扫盘零开销)
/// - 慢路径:读锁 None → 释放后 `scan_skills()` 填充(双检锁,避免持写锁扫盘阻塞读)
/// - `invalidate_skills()` 写锁置 None,下次 `skills_cached()` 触发重扫
/// - `ai_reload_skills` IPC:invalidate + 重扫,进程内热重载(不重启生效)
static SKILLS: RwLock<Option<Vec<SkillInfo>>> = RwLock::new(None);
/// 读锁快路径 guard(供 `read_skill_content_stripped` 持引用迭代读缓存)。
///
/// 生命周期 `'static`:SKILLS 是 static 项,对其借用可标注 `'static`(Rust 对 static 的保证),
/// 使函数能返回 guard 跨作用域传递。guard Drop 时释放读锁。
type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
/// 取读锁快照(懒初始化:None 时先释放锁扫盘填回,再读锁取引用)。
///
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
/// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。
fn skills_lock() -> SkillsGuard {
// 快路径:读锁命中
{
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
if g.is_some() {
return g;
}
}
// 慢路径:扫盘 + 写锁填回
let scanned = scan_skills().skills;
{
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
// 另一线程可能已填,二次检查(双检锁)
if g.is_none() {
*g = Some(scanned);
}
}
// 再取读锁返回(此时必 Some)
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
debug_assert!(g.is_some(), "skills_lock 慢路径后必 Some");
g
}
/// 技能扫描结果缓存(进程内;命中即 clone,不重复扫盘)。
///
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`clone),
/// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。
pub(crate) fn skills_cached() -> Vec<SkillInfo> {
let g = skills_lock();
g.clone().unwrap_or_default()
}
/// 置缓存为 None,下次 `skills_cached()` 触发重扫。
///
/// `ai_reload_skills` IPC 调用:写锁置 None → 紧接 `skills_cached()` 重扫,
/// 实现"改技能不重启即生效"。
pub(crate) fn invalidate_skills() {
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
*g = None;
}
/// 按 name 读技能正文(剥首个 `---...---` frontmatter 块)。
///
/// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。
/// 缓存未命中返 None;文件读失败返 None。
pub(crate) fn read_skill_content_stripped(name: &str) -> Option<String> {
let g = skills_lock();
let skills = g.as_ref()?;
let info = skills.iter().find(|s| s.name == name)?;
let md = fs::read_to_string(&info.path).ok()?;
Some(strip_frontmatter(&md))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_frontmatter_normal() {
let md = "---\nname: foo\ndescription: bar\n---\n# Body\ncontent";
assert_eq!(strip_frontmatter(md), "# Body\ncontent");
}
#[test]
fn strip_frontmatter_no_fm() {
let md = "# Title\nbody line";
assert_eq!(strip_frontmatter(md), "# Title\nbody line");
}
#[test]
fn strip_frontmatter_empty() {
assert_eq!(strip_frontmatter(""), "");
}
#[test]
fn strip_frontmatter_only_fm() {
// 只有 frontmatter 无正文
let md = "---\nname: foo\n---\n";
assert_eq!(strip_frontmatter(md), "");
}
#[test]
fn strip_frontmatter_crlf() {
let md = "---\r\nname: foo\r\n---\r\nbody\r\n";
let out = strip_frontmatter(md);
assert!(out.contains("body"), "CRLF 正文应保留: {:?}", out);
}
#[test]
fn scan_result_dedup_and_conflicts() {
// 仅测去重逻辑(不依赖 ~/.claude 存在);通过 parse_skill_file 间接覆盖
// scan_skills 本身依赖文件系统,此处不集成测
let _ = ScanResult {
skills: vec![],
conflicts: vec![],
};
}
}