Files
DevFlow/src-tauri/src/commands/ai/skills.rs
T
lxy 864c696b70 优化: token消息级持久化 + 技能注入修复 + TopBar减法/UI调
token持久化(方案A,治压缩/切会话后历史token不显):ChatMessage/AiMessageRecord 加 prompt_tokens/completion_tokens(serde + DB V38 迁移 + message_repo 映射);agentic push_assistant_message 设本轮 token + provider/title 构造默认 None;前端 AiMessage 加字段 + switchConversation reload 映射 tokenUsage(双轨:消息级新+会话级旧累计保留)

技能注入修复:read_skill_content_stripped 改 skills_cached 扫盘(防御 SKILLS None 致不注入)+ 细化诊断(缓存/path/fs 各步)

TopBar减法/UI:删铅笔新建(与侧栏+重复)/删垃圾桶clear-chat(危险,clear-context归档替代)/删系统就绪装饰占位;更多菜单popout CSS补全(修样式错乱);provider绿点有信息化(绿/红/灰基于AI请求成败)+垂直居中;goals面板补top:100%(修位置飘)+dot/check/remove CSS+goals/history item统一+history index边距;4面板互斥(点一个收其他)

MessageList token v-if 去 !streaming(修发新消息历史token消失)
2026-08-02 18:17:30 +08:00

459 lines
18 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 时释放 → 扫盘 → 写锁填回 → 读锁重取。
///
/// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces
/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包
/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。
///
/// 锁中毒(P1-260617-3 加固):读写锁 expect 中毒会 panic,技能加载热路径 panic 不可接受。
/// 中毒 → tracing::error! 记录 + 返 None(调用方 `skills_cached` 得空 Vec / `read_skill_content_stripped`
/// 返 None),不再 panic。中毒通常因持锁 panicking 线程(早期改 *g 时 unwrap)残留,缓存本身可重建,
/// 返空后下次 `invalidate_skills` 或进程重启自愈。
async fn skills_lock_async() -> Option<SkillsGuard> {
// 快路径:读锁命中(无 fs,纯内存)
{
// 锁中毒不 panic:PoisonError 携 guard 仍可恢复数据,但缓存一致性不保 → 记录后返 None 降级空。
let g = match RwLock::read(&SKILLS) {
Ok(g) => g,
Err(_) => {
tracing::error!("SKILLS 读锁中毒,返空技能列表");
return None;
}
};
if g.is_some() {
return Some(g);
}
}
// 慢路径:扫盘(spawn_blocking 隔离同步 fs 递归,防阻塞 tokio runtime)
let scanned = tokio::task::spawn_blocking(scan_skills)
.await
.map(|res| res.skills)
.unwrap_or_default();
{
let mut g = match RwLock::write(&SKILLS) {
Ok(g) => g,
Err(_) => {
tracing::error!("SKILLS 写锁中毒,返空技能列表");
return None;
}
};
// 另一线程可能已填,二次检查(双检锁)
if g.is_none() {
*g = Some(scanned);
}
}
// 再取读锁返回(此时必 Some)
let g = match RwLock::read(&SKILLS) {
Ok(g) => g,
Err(_) => {
tracing::error!("SKILLS 读锁(慢路径后)中毒,返空技能列表");
return None;
}
};
debug_assert!(g.is_some(), "skills_lock 慢路径后必 Some");
Some(g)
}
/// 技能扫描结果缓存(进程内;命中即 clone,不重复扫盘)。
///
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`clone),
/// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。
///
/// P1-260617-3:改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking
/// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。
pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
let g = skills_lock_async().await;
// 锁中毒 → None → unwrap_or_default() 得空 Vec(对齐 P1-260617-3 中毒降级)。
g.and_then(|g| g.clone()).unwrap_or_default()
}
/// 置缓存为 None,下次 `skills_cached()` 触发重扫。
///
/// `ai_reload_skills` IPC 调用:写锁置 None → 紧接 `skills_cached()` 重扫,
/// 实现"改技能不重启即生效"。
pub(crate) fn invalidate_skills() {
// 锁中毒:本就是想置 None 清缓存重建,但中毒时持锁线程已 panic,此处无法恢复一致状态。
// 记录错误并提前返回(下次加载由 skills_lock_async 中毒降级返空,进程重启自愈)。
let mut g = match RwLock::write(&SKILLS) {
Ok(g) => g,
Err(_) => {
tracing::error!("SKILLS 写锁中毒(invalidate_skills),跳过置 None");
return;
}
};
*g = None;
}
/// 按 name 读技能正文(剥首个 `---...---` frontmatter 块)。
///
/// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。
/// 缓存未命中返 None;文件读失败返 None。
///
/// P1-260617-3:改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。
///
/// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop,
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
pub(crate) async fn read_skill_content_stripped(name: String) -> Option<String> {
// 用 skills_cached(扫盘填充 + clone)替代 skills_lock_async(只读):防御 SKILLS 为 None
// (首次/invalidate 后)时 ? 早返 None 致技能不注入。skills_cached 触发 get_or_init 扫盘确保填充。
let path: Option<String> = {
let skills = skills_cached().await;
skills.iter().find(|s| s.name == name).map(|s| s.path.clone())
};
let path = match path {
Some(p) => p,
None => {
tracing::warn!(skill = %name, "[read_skill_content_stripped] 缓存中未找到该技能(name 不匹配/未扫到/user_invocable:false)");
return None;
}
};
// 单文件读(spawn_blocking 防 Windows fs 慢),细化诊断各失败步便于定位真断点。
// path move 进 spawn_blocking,先 clone 留诊断用(tracing 借用 move 后值会 E0382)。
let path_for_log = path.clone();
let md = match tokio::task::spawn_blocking(move || fs::read_to_string(&path)).await {
Ok(Ok(content)) => content,
Ok(Err(e)) => {
tracing::warn!(skill = %name, path = %path_for_log, error = %e, "[read_skill_content_stripped] 读 SKILL.md 失败(路径/权限/编码)");
return None;
}
Err(e) => {
tracing::warn!(skill = %name, error = %e, "[read_skill_content_stripped] spawn_blocking join 失败");
return None;
}
};
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![],
};
}
}