修复: 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:
@@ -1680,7 +1680,7 @@ fn register_file_tools(
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
registry.register(
|
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,
|
grep_schema,
|
||||||
RiskLevel::Low,
|
RiskLevel::Low,
|
||||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
{ 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 matches_out: Vec<FileGrepHit> = Vec::new();
|
||||||
let mut total: usize = 0;
|
let mut total: usize = 0;
|
||||||
let mut truncated = false;
|
let mut truncated = false;
|
||||||
grep_recursive(
|
// BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。
|
||||||
root, &re, glob_re.as_ref(), output_mode,
|
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
|
||||||
context_lines, max_results, 0, 6,
|
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
|
||||||
&mut matches_out, &mut total, &mut truncated,
|
let root_meta = tokio::fs::metadata(root).await
|
||||||
).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 分派返回结构
|
// output_mode 分派返回结构
|
||||||
let result = match output_mode {
|
let result = match output_mode {
|
||||||
@@ -2304,102 +2320,123 @@ fn grep_recursive<'a>(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 文件:跳过噪音文件(.bak/.tmp-write 等编辑器产物)
|
// 文件处理(BUG-260625-01:提取为 grep_one_file,grep handler 单文件路径复用同一逻辑)
|
||||||
if is_noise_file(&file_name) {
|
grep_one_file(&path, &file_name, re, glob, output_mode, context_lines, max_results, out, total, truncated).await?;
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
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: ®ex::Regex,
|
||||||
|
glob: Option<®ex::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>(
|
fn search_files_recursive<'a>(
|
||||||
path: &'a str,
|
path: &'a str,
|
||||||
@@ -2695,7 +2732,7 @@ mod tests {
|
|||||||
// 若 persistent 存 canonicalize 带 \\?\,starts_with 双侧形态不对齐恒 false → 误判未授权)。
|
// 若 persistent 存 canonicalize 带 \\?\,starts_with 双侧形态不对齐恒 false → 误判未授权)。
|
||||||
let mut persistent = std::collections::HashSet::new();
|
let mut persistent = std::collections::HashSet::new();
|
||||||
persistent.insert(tmp.clone());
|
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 = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||||
let db = Arc::new(db);
|
let db = Arc::new(db);
|
||||||
@@ -2722,7 +2759,7 @@ mod tests {
|
|||||||
// persistent 存词法形态(对齐 is_authorized 比对,见 test_read_file_no_offset_respects_limit 注释)
|
// persistent 存词法形态(对齐 is_authorized 比对,见 test_read_file_no_offset_respects_limit 注释)
|
||||||
let mut persistent = std::collections::HashSet::new();
|
let mut persistent = std::collections::HashSet::new();
|
||||||
persistent.insert(tmp.clone());
|
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 = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||||
let db = Arc::new(db);
|
let db = Arc::new(db);
|
||||||
|
|||||||
@@ -2,19 +2,33 @@
|
|||||||
<!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
<!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
||||||
分支顺序/条件/文案与原 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 v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
||||||
<div class="ai-tool-file-bar">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||||
<span class="ai-tool-file-path">{{ parsed?.path }}</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 })
|
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
|
||||||
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
|
: `${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') }}
|
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- list_directory 结果:文件树 -->
|
<!-- list_directory 结果:文件树 -->
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export interface ToolResult {
|
|||||||
body?: string
|
body?: string
|
||||||
body_bytes?: number
|
body_bytes?: number
|
||||||
elapsed_ms?: number
|
elapsed_ms?: number
|
||||||
|
// read_file search 模式(BUG-260625-02):传 search 参数返命中行 matches[{line,content}] 非全文 content
|
||||||
|
search?: string
|
||||||
// grep 跨文件内容搜索结果字段(F-260621):
|
// grep 跨文件内容搜索结果字段(F-260621):
|
||||||
// - output_mode: content(默认)/ files_with_matches / count
|
// - output_mode: content(默认)/ files_with_matches / count
|
||||||
// - matches: content 模式命中行 [{file,line,content,context?}]
|
// - matches: content 模式命中行 [{file,line,content,context?}]
|
||||||
|
|||||||
Reference in New Issue
Block a user