优化: aichat 工具体验补强(切生成会话占位气泡治丢首响应/read_symbol符号级提示/失败引导/审批失败toast) + 修复 WorkflowDagDisplay 崩页与 aiChat i18n 缺key
This commit is contained in:
@@ -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