From 047b3b91faa49ed267611a02cb199c020ff83cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 25 Jun 2026 00:27:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20read=5Ffile/grep=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BB=93=E6=9E=9C=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-260625-01 grep 单文件路径走 grep_one_file(旧 read_dir 报 os error 267 目录无效);BUG-260625-02 read_file search 模式渲染命中行 matches(旧取 content 显 0 行误判读取失败)。 --- src-tauri/src/commands/ai/tool_registry.rs | 235 ++++++++++++--------- src/components/ToolResultBody.vue | 22 +- src/composables/ai/useToolCard.ts | 2 + 3 files changed, 156 insertions(+), 103 deletions(-) diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 3320b62..6ba22e7 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -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 = 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 = 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::(); - 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: ®ex::Regex, + glob: Option<®ex::Regex>, + output_mode: &str, + context_lines: usize, + max_results: usize, + out: &mut Vec, + 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 = 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::(); + 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); diff --git a/src/components/ToolResultBody.vue b/src/components/ToolResultBody.vue index 1b3b24a..ad26a92 100644 --- a/src/components/ToolResultBody.vue +++ b/src/components/ToolResultBody.vue @@ -2,19 +2,33 @@ - +
{{ parsed?.path }} - {{ parsed?.has_more + + 「{{ parsed?.search }}」 {{ $t('aiTool.grepHitsN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.matches?.length || 0) }) }} + {{ parsed?.has_more ? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 }) : `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }} -
-
{{ parsed?.content }}
+ +
+
+
+ :{{ m.line }} +
+ {{ m.content }} +
+
+ +
{{ parsed?.content }}
diff --git a/src/composables/ai/useToolCard.ts b/src/composables/ai/useToolCard.ts index 3ddbfad..860a164 100644 --- a/src/composables/ai/useToolCard.ts +++ b/src/composables/ai/useToolCard.ts @@ -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?}]