优化: aichat 工具体验补强(切生成会话占位气泡治丢首响应/read_symbol符号级提示/失败引导/审批失败toast) + 修复 WorkflowDagDisplay 崩页与 aiChat i18n 缺key

This commit is contained in:
lxy
2026-08-09 21:34:53 +08:00
parent cad76ffdf2
commit 37040616bd
10 changed files with 335 additions and 21 deletions
+98
View File
@@ -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"));
}
}
+147 -13
View File
@@ -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}");
}
}