新增: Wave8 F-06 导入项目 + F-02 技能联想 + AR-6 Low 失败语义

- F-06 导入历史项目:scan.rs extract_description(README 首段 200 字) + import_project 命令(合并 create+bind,spawn_blocking 探测栈) + 前端 ProjectDetail 导入按钮 + store action;主代理补 lib.rs invoke_handler 注册(agent 未注册)
- F-02 技能联想使用:核对发现 sendMessage→ai_chat_send skill 调用链已完整(联想选中→带 skill→注入 SKILL.md),补 Esc 取消选中兜底
- AR-6 Low 失败语义统一(定向B):audit.rs Low 失败 emit AiToolCallCompleted(错误 result) 替代 AiError,消除前端 false/后端 loop 续紊乱,错误回填 tool_result 让 LLM 自处理
cargo/vue-tsc 0 err
This commit is contained in:
2026-06-14 22:13:51 +08:00
parent b195a38d64
commit f82dd8ba90
8 changed files with 248 additions and 5 deletions

View File

@@ -126,6 +126,51 @@ fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
// ============================================================
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
///
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
// 复用 read_readme 的查找逻辑(支持 README.md / README.zh.md 等变体)
let content = read_readme(root)?;
let mut text = String::new();
let mut started = false;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() {
if started {
break; // 段落结束
}
continue; // 首段尚未开始,跳过开头空行
}
// 段落开始后不再跳行,直接累加
if !started {
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
if line.starts_with('#')
|| line.starts_with("<!--")
|| line.starts_with('!')
|| line.starts_with('<')
{
continue;
}
started = true;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(line);
}
let desc = text.trim().to_string();
if desc.is_empty() {
return None;
}
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
}
/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;
/// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段
#[derive(Debug, Clone)]
pub struct ProjectSample {
@@ -313,4 +358,41 @@ mod tests {
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_skips_title_badge() {
let d = scratch("desc");
// 标题 + badge 图片行应跳过,首段为正文
fs::write(
d.join("README.md"),
"# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
)
.unwrap();
let desc = extract_description(&d).unwrap();
assert!(desc.contains("示例项目"));
assert!(desc.contains("第二行正文"));
assert!(!desc.contains("My Project"));
assert!(!desc.contains("badge"));
assert!(!desc.contains("安装"));
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_no_readme_returns_none() {
let d = scratch("nodesc");
assert!(extract_description(&d).is_none());
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_truncates_long() {
let d = scratch("longdesc");
let long = "".repeat(500);
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
let desc = extract_description(&d).unwrap();
// 截断到 200 字 + 末尾「…(已截断)」标记
assert!(desc.chars().count() <= 210);
assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok();
}
}