优化: 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
+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}");
}
}