优化: aichat 工具体验补强(切生成会话占位气泡治丢首响应/read_symbol符号级提示/失败引导/审批失败toast) + 修复 WorkflowDagDisplay 崩页与 aiChat i18n 缺key
This commit is contained in:
@@ -92,6 +92,56 @@ pub fn is_supported_ext(ext: &str) -> bool {
|
||||
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py")
|
||||
}
|
||||
|
||||
/// 判断 source 内容是否真的含可提取的函数/类/结构体定义(AC-4 read_file 符号级命中提示)。
|
||||
///
|
||||
/// read_file 读取成功后据此决定是否追加「改用 read_symbol」提示:仅当内容里真的存在
|
||||
/// DEFINITION_KINDS 定义节点才提示——比旧按扩展名判断更准,无定义的文件不瞎提示。
|
||||
/// 与 read_symbol 同语法树逻辑(grammar_for + DEFINITION_KINDS + Vue 切 <script> 段借 ts),
|
||||
/// 但不提取符号只探测存在性,开销低。**复用现有函数,不新写正则**。
|
||||
/// 无 grammar / 解析失败 / Vue 无 script / 无定义节点 → false(不提示)。
|
||||
pub(crate) fn contains_definition(source: &str, ext: &str) -> bool {
|
||||
let ext_lower = ext.to_lowercase();
|
||||
let (parse_source, parse_ext): (String, &str) = if ext_lower == "vue" {
|
||||
// Vue 逻辑在 <script> 段,切段借 ts(与 read_symbol 的 extract_vue_script 同源)
|
||||
match extract_vue_script(source) {
|
||||
Some((script, _)) => (script, "ts"),
|
||||
None => return false, // 纯 template/style 无可提取符号
|
||||
}
|
||||
} else {
|
||||
(source.to_string(), ext_lower.as_str())
|
||||
};
|
||||
|
||||
let grammar = match grammar_for(parse_ext) {
|
||||
Some(g) => g,
|
||||
None => return false, // 不支持的语言不提示
|
||||
};
|
||||
|
||||
let mut parser = Parser::new();
|
||||
if parser.set_language(&grammar).is_err() {
|
||||
return false; // grammar 加载失败(ABI 不兼容)不提示
|
||||
}
|
||||
let tree = match parser.parse(&parse_source, None) {
|
||||
Some(t) => t,
|
||||
None => return false, // 解析失败不提示
|
||||
};
|
||||
|
||||
tree_has_definition(&tree.root_node())
|
||||
}
|
||||
|
||||
/// 递归遍历语法树,判断是否含任一 DEFINITION_KINDS 定义节点(contains_definition 的遍历内核)。
|
||||
fn tree_has_definition(node: &Node<'_>) -> bool {
|
||||
if DEFINITION_KINDS.contains(&node.kind()) {
|
||||
return true;
|
||||
}
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if tree_has_definition(&child) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。
|
||||
///
|
||||
/// Vue 单文件组件逻辑在 `<script setup lang="ts">` 内,template/style 不做符号提取。
|
||||
@@ -633,4 +683,52 @@ fn outer() {
|
||||
let calls = skeleton["calls"].as_array().expect("骨架应含 calls");
|
||||
assert!(!calls.is_empty(), "应提取出内部调用");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// contains_definition — AC-4 read_file 符号级命中检测
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn test_contains_definition_hits_definitions() {
|
||||
// 含 fn/struct 定义 → true(应提示改用 read_symbol)
|
||||
assert!(contains_definition(RUST_FIXTURE, "rs"));
|
||||
assert!(contains_definition("pub fn main() {}\n", "rs"));
|
||||
assert!(contains_definition("struct Config { name: String }\n", "rs"));
|
||||
// TS/JS
|
||||
assert!(contains_definition("export function greet() { return 1; }", "ts"));
|
||||
assert!(contains_definition("const handler = () => {};", "js"));
|
||||
// Go / Java / Python
|
||||
assert!(contains_definition("package main\nfunc main() {}\n", "go"));
|
||||
assert!(contains_definition("class Foo { void bar() {} }", "java"));
|
||||
assert!(contains_definition("def foo():\n pass\n", "py"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_definition_plain_text_false() {
|
||||
// 无定义节点的纯文本/纯注释 → false(符号级命中,不瞎提示)
|
||||
assert!(!contains_definition("just some text\nwith no definitions\n", "rs"));
|
||||
assert!(!contains_definition("// 只有注释\n/* 多行注释 */\n", "rs"));
|
||||
// 字符串字面量里有 fn 字样但不是定义
|
||||
assert!(!contains_definition("let msg = \"fn main() {}\";\nconsole.log(msg);", "js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_definition_unsupported_ext_or_parse_fail() {
|
||||
// Ruby 无 grammar → false
|
||||
assert!(!contains_definition("def foo()\n puts 'hi'\nend\n", "rb"));
|
||||
// 无扩展名 → false
|
||||
assert!(!contains_definition("any content", ""));
|
||||
// 大小写不敏感("RS" 等价 "rs")
|
||||
assert!(contains_definition("pub fn main() {}\n", "RS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_definition_vue() {
|
||||
// Vue 含 <script> 函数 → true
|
||||
let vue = "<template><div>{{ msg }}</div></template>\n\n<script setup lang=\"ts\">\nfunction greet() {}\n</script>\n";
|
||||
assert!(contains_definition(vue, "vue"));
|
||||
// 无 <script> 段(纯 template/style)→ false
|
||||
let no_script = "<template><div>hi</div></template>\n<style></style>";
|
||||
assert!(!contains_definition(no_script, "vue"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +87,7 @@ pub fn register(
|
||||
let mut file = File::open(path).await
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
anyhow::anyhow!(
|
||||
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
|
||||
path
|
||||
)
|
||||
file_not_found_error(path)
|
||||
} else {
|
||||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||||
}
|
||||
@@ -182,14 +179,13 @@ pub fn register(
|
||||
consumed,
|
||||
))
|
||||
} else { None };
|
||||
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且扩展名在 read_symbol
|
||||
// 支持语言集合内,则末尾追加一句引导提示改用 read_symbol 精确提取符号,避免读全文件
|
||||
// 浪费上下文。复用 code_intel::is_supported_ext 单真相源(与 read_symbol handler 语言
|
||||
// 集合一致,不重复定义)。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
|
||||
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且**符号级命中**检测通过
|
||||
// (内容里真的含可提取的函数/类/结构体定义)才追加 read_symbol 引导——比旧按扩展名
|
||||
// 判断更准,无定义的文件不瞎提示。用全量 content(非分页 result)检测,符号可能在
|
||||
// offset 范围外。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
|
||||
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
let content_field = if offset_used.is_none()
|
||||
&& crate::commands::ai::code_intel::is_supported_ext(
|
||||
path.rsplit('.').next().unwrap_or("").to_lowercase().as_str(),
|
||||
)
|
||||
&& crate::commands::ai::code_intel::contains_definition(&content, &ext)
|
||||
{
|
||||
format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。")
|
||||
} else {
|
||||
@@ -227,7 +223,13 @@ pub fn register(
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut file = File::open(path).await
|
||||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
file_not_found_error(path)
|
||||
} else {
|
||||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||||
}
|
||||
})?;
|
||||
let metadata = file.metadata().await
|
||||
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
||||
if metadata.len() > 1_048_576 {
|
||||
@@ -1098,7 +1100,15 @@ pub fn register(
|
||||
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
|
||||
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_results.len()) < total as usize;
|
||||
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
|
||||
let mut out = serde_json::json!({
|
||||
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
|
||||
});
|
||||
// AC-5:total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
|
||||
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
|
||||
if total == 0 {
|
||||
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1380,6 +1390,83 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String
|
||||
scored.into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选(AC-5)
|
||||
// ============================================================
|
||||
|
||||
/// 父目录下与 needle 文件名最相近的 max 个文件名(Dice 字符多重集,思路同 similar_line_fragments)。
|
||||
///
|
||||
/// 用途:read_file/read_symbol 打开文件 NotFound 时,把父目录下最可能的文件名候选附进错误提示,
|
||||
/// 治 LLM 拼错文件名/少写扩展名后盲目重试(AC-5)。低频错误路径(文件不存在),同步 std::fs::read_dir
|
||||
/// 枚举可接受(目录通常不大)。阈值 40% 防误导(完全无关文件名不提示);父目录不可读/非目录返空。
|
||||
fn similar_file_names(dir: &std::path::Path, needle: &str, max: usize) -> Vec<String> {
|
||||
if needle.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// 预计算 needle 字符多重集计数(各文件名评分复用,同 similar_line_fragments 的探针计数)
|
||||
use std::collections::HashMap;
|
||||
let mut needle_counts: HashMap<char, u32> = HashMap::new();
|
||||
for c in needle.chars() {
|
||||
*needle_counts.entry(c).or_insert(0) += 1;
|
||||
}
|
||||
let needle_len = needle.chars().count() as u32;
|
||||
|
||||
let mut scored: Vec<(u32, String)> = Vec::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(iter) => iter,
|
||||
Err(_) => return Vec::new(), // 父目录不可读/非目录 → 无候选,退回原提示
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let name_len = name.chars().count() as u32;
|
||||
if name == needle || name_len == 0 {
|
||||
continue;
|
||||
}
|
||||
// 公共字符计数(needle 计数约束下扫描文件名)
|
||||
let mut counts = needle_counts.clone();
|
||||
let mut common = 0u32;
|
||||
for c in name.chars() {
|
||||
if let Some(cnt) = counts.get_mut(&c) {
|
||||
if *cnt > 0 {
|
||||
*cnt -= 1;
|
||||
common += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dice = 2*common / (needle_len + name_len),乘 100 转百分比整数(同 similar_line_fragments)
|
||||
let dice = (common * 200) / (needle_len + name_len).max(1);
|
||||
if dice >= 40 {
|
||||
scored.push((dice, name));
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
scored.truncate(max);
|
||||
scored.into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
/// 文件打开 NotFound 的统一错误文案:基础提示 + 父目录相近文件名候选(AC-5)。
|
||||
///
|
||||
/// read_file / read_symbol 共用(DRY)。保留原「建议用 list_directory」引导;候选为空不追加。
|
||||
fn file_not_found_error(path: &str) -> anyhow::Error {
|
||||
let mut msg = format!(
|
||||
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
|
||||
path
|
||||
);
|
||||
let p = std::path::Path::new(path);
|
||||
if let (Some(parent), Some(file_name)) = (p.parent(), p.file_name()) {
|
||||
let needle = file_name.to_string_lossy().into_owned();
|
||||
let candidates = similar_file_names(parent, &needle, 3);
|
||||
if !candidates.is_empty() {
|
||||
msg.push_str(&format!(
|
||||
"。相近文件候选:\n{}\n(可核对实际文件名)",
|
||||
candidates.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
anyhow::anyhow!(msg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1571,6 +1658,53 @@ mod tests {
|
||||
"多行 old_text 应命中首行所在行,实际: {hits:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn similar_file_names_finds_close_name() {
|
||||
// 临时目录放真实文件名,needle 少写一个字符(uilts.rs)应命中 utils.rs
|
||||
let dir = std::env::temp_dir().join(format!("df-similar-files-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for name in ["main.rs", "mod.rs", "utils.rs", "README.md"] {
|
||||
std::fs::write(dir.join(name), "").unwrap();
|
||||
}
|
||||
let hits = similar_file_names(&dir, "uilts.rs", 3);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(
|
||||
hits.iter().any(|h| h == "utils.rs"),
|
||||
"相近文件名应命中 utils.rs,实际: {hits:?}"
|
||||
);
|
||||
// max 截断
|
||||
assert!(hits.len() <= 3, "候选数应不超过 max,实际: {}", hits.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similar_file_names_empty_when_no_relation() {
|
||||
let dir = std::env::temp_dir().join(format!("df-similar-empty-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("readme.md"), "").unwrap();
|
||||
let hits = similar_file_names(&dir, "config.yaml", 3);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(hits.is_empty(), "无关文件名不应给候选,实际: {hits:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_not_found_error_appends_candidates() {
|
||||
// 少写一个字符的拼写 → 错误文案应含 target.rs 候选 + 保留 list_directory 引导
|
||||
let dir = std::env::temp_dir().join(format!("df-notfound-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("target.rs"), "fn main() {}\n").unwrap();
|
||||
let missing = dir.join("targett.rs");
|
||||
let msg = file_not_found_error(&missing.to_string_lossy()).to_string();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(msg.contains("路径不存在"), "应保留基础提示,实际: {msg}");
|
||||
assert!(msg.contains("list_directory"), "应保留 list_directory 引导,实际: {msg}");
|
||||
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
|
||||
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user