修复: read_file/grep 工具结果处理

BUG-260625-01 grep 单文件路径走 grep_one_file(旧 read_dir 报 os error 267 目录无效);BUG-260625-02 read_file search 模式渲染命中行 matches(旧取 content 显 0 行误判读取失败)。
This commit is contained in:
2026-06-25 00:27:02 +08:00
parent 24c3fa08f1
commit 047b3b91fa
3 changed files with 156 additions and 103 deletions

View File

@@ -1680,7 +1680,7 @@ fn register_file_tools(
})
};
registry.register(
"grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,锚 workspace + path_auth 授权)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
"grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,文件或目录均可——传单文件仅搜该文件,锚 workspace + path_auth 授权)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
grep_schema,
RiskLevel::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
@@ -1722,11 +1722,27 @@ fn register_file_tools(
let mut matches_out: Vec<FileGrepHit> = Vec::new();
let mut total: usize = 0;
let mut truncated = false;
grep_recursive(
root, &re, glob_re.as_ref(), output_mode,
context_lines, max_results, 0, 6,
&mut matches_out, &mut total, &mut truncated,
).await?;
// BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
let root_meta = tokio::fs::metadata(root).await
.map_err(|e| anyhow::anyhow!("无法访问路径 {}: {}", root, e))?;
if root_meta.is_file() {
let file_name = std::path::Path::new(root)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
grep_one_file(
std::path::Path::new(root), &file_name, &re, glob_re.as_ref(), output_mode,
context_lines, max_results, &mut matches_out, &mut total, &mut truncated,
).await?;
} else {
grep_recursive(
root, &re, glob_re.as_ref(), output_mode,
context_lines, max_results, 0, 6,
&mut matches_out, &mut total, &mut truncated,
).await?;
}
// output_mode 分派返回结构
let result = match output_mode {
@@ -2304,102 +2320,123 @@ fn grep_recursive<'a>(
}
continue;
}
// 文件:跳过噪音文件(.bak/.tmp-write 等编辑器产物)
if is_noise_file(&file_name) {
continue;
}
// glob 过滤(单段匹配文件名,glob_re 已锚定 ^...$ 整段匹配)
if let Some(g) = glob {
if !g.is_match(&file_name) {
continue;
}
}
// 单文件读取:1MB 上限 + 二进制检测(\0)。读失败静默跳过(权限/竞态,不阻断整体搜索)
let metadata = match tokio::fs::metadata(&path).await {
Ok(m) => m,
Err(_) => continue,
};
if metadata.len() > 1_048_576 {
continue; // >1MB 跳过(对齐 read_file 上限)
}
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(_) => continue,
};
// 二进制检测:前 8KB 含 \0 视为二进制跳过(对齐 file_info/read_file)
if bytes.iter().take(8192).any(|&b| b == 0) {
continue;
}
// 转 UTF-8(lossy 容错非 UTF-8 残片,二进制已挡多数情况)
let content = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = content.lines().collect();
// files_with_matches 模式:仅记文件级命中,不收集行详情。
// content/count 模式:行级命中,需逐行检查 max_results 截断(单文件可能多行命中,
// 超过 max_results 的行只计入 total 不入 out)。
if output_mode == "files_with_matches" {
// 检查文件数是否已达上限:达则本文件只计入 total 不入 out(设截断)
let mut has_hit = false;
for line in lines.iter() {
if re.is_match(line) {
*total += 1;
has_hit = true;
}
}
if !has_hit {
continue; // 本文件无命中
}
if out.len() >= max_results {
*truncated = true; // 已达文件数上限,只计入 total
} else {
out.push(FileGrepHit {
file: path.to_string_lossy().into_owned(),
line_matches: Vec::new(), // files_with_matches 不收集行详情
});
}
continue;
}
// content/count 模式:逐行收集,达 max_results 行数上限即截断(剩余行只计 total)
let mut line_matches: Vec<LineMatch> = Vec::new();
let mut file_has_hit = false;
// 已收集命中行累计计数(O(1) 维护):初值=前序文件累计收集数(本文件内逐次自增)
// 取代每次命中全量重算 out.iter().map(...).sum() 的 O(n²) 统计。
let mut collected = out.iter().map(|h| h.line_matches.len()).sum::<usize>();
for (idx, line) in lines.iter().enumerate() {
if re.is_match(line) {
*total += 1;
file_has_hit = true;
// 当前已收集行数达上限 → 截断,不再收集
if collected >= max_results {
*truncated = true;
} else {
let context = if context_lines > 0 {
let start = idx.saturating_sub(context_lines);
let end = (idx + context_lines + 1).min(lines.len());
lines[start..end].join("\n")
} else {
String::new()
};
line_matches.push(LineMatch {
line: idx + 1, // 1-based
content: line.to_string(),
context,
});
collected += 1; // 命中收集后累计计数自增(O(1))
}
}
}
if file_has_hit && !line_matches.is_empty() {
// 本文件有命中且有收集(全截断的文件不入 out,避免空 line_matches 污染)
out.push(FileGrepHit {
file: path.to_string_lossy().into_owned(),
line_matches,
});
}
// 文件处理(BUG-260625-01:提取为 grep_one_file,grep handler 单文件路径复用同一逻辑)
grep_one_file(&path, &file_name, re, glob, output_mode, context_lines, max_results, out, total, truncated).await?;
}
Ok(())
})
}
/// 单文件 grep:读 + 二进制检测 + 行匹配,收集命中到 out。
/// 提取自 grep_recursive 循环体(BUG-260625-01),供 grep handler 单文件路径复用——
/// grep 命令行原生支持单文件,LLM 常传文件路径而非目录,旧实现直接 read_dir 文件路径报 os error 267。
/// files_with_matches:文件级命中(content/count 不收集行详情);content/count:行级命中,达 max_results 截断。
async fn grep_one_file(
path: &std::path::Path,
file_name: &str,
re: &regex::Regex,
glob: Option<&regex::Regex>,
output_mode: &str,
context_lines: usize,
max_results: usize,
out: &mut Vec<FileGrepHit>,
total: &mut usize,
truncated: &mut bool,
) -> anyhow::Result<()> {
// 噪音文件(.bak/.tmp-write 等编辑器产物)跳过
if is_noise_file(file_name) {
return Ok(());
}
// glob 过滤(单段匹配文件名,glob_re 已锚定 ^...$ 整段匹配)
if let Some(g) = glob {
if !g.is_match(file_name) {
return Ok(());
}
}
// 单文件读取:1MB 上限 + 二进制检测(\0)。读失败静默跳过(权限/竞态,不阻断整体搜索)
let metadata = match tokio::fs::metadata(path).await {
Ok(m) => m,
Err(_) => return Ok(()),
};
if metadata.len() > 1_048_576 {
return Ok(()); // >1MB 跳过(对齐 read_file 上限)
}
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
Err(_) => return Ok(()),
};
// 二进制检测:前 8KB 含 \0 视为二进制跳过(对齐 file_info/read_file)
if bytes.iter().take(8192).any(|&b| b == 0) {
return Ok(());
}
// 转 UTF-8(lossy 容错非 UTF-8 残片,二进制已挡多数情况)
let content = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = content.lines().collect();
// files_with_matches 模式:仅记文件级命中,不收集行详情。
// content/count 模式:行级命中,需逐行检查 max_results 截断(单文件可能多行命中,
// 超过 max_results 的行只计入 total 不入 out)。
if output_mode == "files_with_matches" {
// 检查文件数是否已达上限:达则本文件只计入 total 不入 out(设截断)
let mut has_hit = false;
for line in lines.iter() {
if re.is_match(line) {
*total += 1;
has_hit = true;
}
}
if !has_hit {
return Ok(()); // 本文件无命中
}
if out.len() >= max_results {
*truncated = true; // 已达文件数上限,只计入 total
} else {
out.push(FileGrepHit {
file: path.to_string_lossy().into_owned(),
line_matches: Vec::new(), // files_with_matches 不收集行详情
});
}
return Ok(());
}
// content/count 模式:逐行收集,达 max_results 行数上限即截断(剩余行只计 total)
let mut line_matches: Vec<LineMatch> = Vec::new();
let mut file_has_hit = false;
// 已收集命中行累计计数(O(1) 维护):初值=前序文件累计收集数(本文件内逐次自增)
// 取代每次命中全量重算 out.iter().map(...).sum() 的 O(n²) 统计。
let mut collected = out.iter().map(|h| h.line_matches.len()).sum::<usize>();
for (idx, line) in lines.iter().enumerate() {
if re.is_match(line) {
*total += 1;
file_has_hit = true;
// 当前已收集行数达上限 → 截断,不再收集
if collected >= max_results {
*truncated = true;
} else {
let context = if context_lines > 0 {
let start = idx.saturating_sub(context_lines);
let end = (idx + context_lines + 1).min(lines.len());
lines[start..end].join("\n")
} else {
String::new()
};
line_matches.push(LineMatch {
line: idx + 1, // 1-based
content: line.to_string(),
context,
});
collected += 1; // 命中收集后累计计数自增(O(1))
}
}
}
if file_has_hit && !line_matches.is_empty() {
// 本文件有命中且有收集(全截断的文件不入 out,避免空 line_matches 污染)
out.push(FileGrepHit {
file: path.to_string_lossy().into_owned(),
line_matches,
});
}
Ok(())
}
/// 递归搜索文件(字符串包含匹配,大小写不敏感)
fn search_files_recursive<'a>(
path: &'a str,
@@ -2695,7 +2732,7 @@ mod tests {
// 若 persistent 存 canonicalize 带 \\?\,starts_with 双侧形态不对齐恒 false → 误判未授权)。
let mut persistent = std::collections::HashSet::new();
persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);
@@ -2722,7 +2759,7 @@ mod tests {
// persistent 存词法形态(对齐 is_authorized 比对,见 test_read_file_no_offset_respects_limit 注释)
let mut persistent = std::collections::HashSet::new();
persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);

View File

@@ -2,19 +2,33 @@
<!-- 结果展示区( ToolCard.vue 抽离,纯渲染,逻辑等价)
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板 -->
<!-- read_file 结果:代码预览 -->
<!-- read_file 结果:全文预览(默认)/ 命中行(search 模式) -->
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.has_more
<!-- BUG-260625-02: search 模式返回 matches(命中行) content,
旧模板取 lines/returned_lines 0 + content ,用户误以为读取失败
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开 -->
<span v-if="parsed?.search" class="ai-tool-file-meta">{{ parsed?.search }} {{ $t('aiTool.grepHitsN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.matches?.length || 0) }) }}</span>
<span v-else class="ai-tool-file-meta">{{ parsed?.has_more
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
<button class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
<button v-if="!parsed?.search" class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
</button>
</div>
<pre class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
<!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches {line,content} file) -->
<div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches">
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
<div v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-match-head">
<span class="ai-tool-grep-line">:{{ m.line }}</span>
</div>
<code class="ai-tool-grep-content">{{ m.content }}</code>
</div>
</div>
<!-- 默认模式:全文预览 -->
<pre v-else class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
</div>
<!-- list_directory 结果:文件树 -->

View File

@@ -85,6 +85,8 @@ export interface ToolResult {
body?: string
body_bytes?: number
elapsed_ms?: number
// read_file search 模式(BUG-260625-02):传 search 参数返命中行 matches[{line,content}] 非全文 content
search?: string
// grep 跨文件内容搜索结果字段(F-260621):
// - output_mode: content(默认)/ files_with_matches / count
// - matches: content 模式命中行 [{file,line,content,context?}]