新增: 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:
@@ -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\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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user