From 6b67214395eee997ce3b5f7daedc98a69b938e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 17 Jun 2026 14:07:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20AI=E5=B7=A5=E5=85=B7OOM/?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E9=81=8D=E5=8E=86+=E7=BB=AD=E8=B7=91?= =?UTF-8?q?=E9=94=81=E6=94=B6=E6=95=9B+UX=E4=BA=A4=E4=BA=92=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + docs/todo.md | 49 +++++----- src-tauri/Cargo.toml | 2 + src-tauri/src/commands/ai/agentic.rs | 68 ++++++++++---- src-tauri/src/commands/ai/conversation.rs | 4 +- src-tauri/src/commands/ai/tool_registry.rs | 104 ++++++++++++++++++--- src/components/ToolCard.vue | 60 +++++++++++- src/components/ToolCardList.vue | 54 ++++++----- src/composables/ai/useAiConversations.ts | 37 +++++++- src/composables/ai/useAiEvents.ts | 31 ++++-- src/composables/ai/useAiSend.ts | 24 ++++- src/i18n/en/ai.ts | 3 + src/i18n/en/aiTool.ts | 24 +++++ src/i18n/zh-CN/ai.ts | 3 + src/i18n/zh-CN/aiTool.ts | 24 +++++ 15 files changed, 390 insertions(+), 98 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3166cd..f935264 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -750,6 +750,7 @@ dependencies = [ "df-workflow", "futures", "keyring", + "percent-encoding", "serde", "serde_json", "tauri", diff --git a/docs/todo.md b/docs/todo.md index 1b58ba1..01c2edd 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1090,22 +1090,22 @@ gap: 8px; #### 🔴 P0 — 确定性 Bug(3 项·建议立即修复) - [ ] **BUG-260617-01 [P0]** — **`classify_status_or_class` 尾部 `|| true` 导致所有 unknown 错误标记 retryable** — `stream_recv.rs:433` 函数末尾硬编码 `|| true`,400/403 等 Fatal 错误被误标为可重试 → agentic loop 无效重试循环浪费 token 和时间。**修法**:移除 `|| true`,unknown 默认 `false`(保守不重试),与 `retry::is_status_retryable` 语义一致。**一行改动,零风险,最高 ROI**。— src-tauri/src/commands/ai/stream_recv.rs:433 -- [ ] **BUG-260617-02 [P0]** — **`file_info` 全量读大文件到内存再截断前 8KB** — `tool_registry.rs:1125-1126` `tokio::fs::read(path)` 将整个文件读入内存(注释说">2MB跳过避免全量读"但实际先全量读再取前 8192 字节做二进制检测)。>2MB 文件触发 OOM 风险。**修法**:用 `File::open` + `read_exact()` 仅读前 N 字节,或加 `.take(8192)` 截断流式读取。— src-tauri/src/commands/ai/tool_registry.rs:1125-1126 -- [ ] **BUG-260617-03 [P0]** — **路径遍历防护可被编码绕过** — `tool_registry.rs:78-95` 只做字符串 `..` 检测 + 小写化,URL 编码 `%2e%2e` / Unicode 同形字符可绕过。**修法**:先 `percent_decode` 再做词法 `..` 分段归一化检查;对不存在路径(new file) 也应规范化校验。— src-tauri/src/commands/ai/tool_registry.rs:78-95 +- [x] **BUG-260617-02 [P0]** — **`file_info` 全量读大文件到内存再截断前 8KB** — `tool_registry.rs:1125-1126` `tokio::fs::read(path)` 将整个文件读入内存(注释说">2MB跳过避免全量读"但实际先全量读再取前 8192 字节做二进制检测)。>2MB 文件触发 OOM 风险。**修法**:用 `File::open` + `read_exact()` 仅读前 N 字节,或加 `.take(8192)` 截断流式读取。— src-tauri/src/commands/ai/tool_registry.rs:1125-1126 +- [x] **BUG-260617-03 [P0]** — **路径遍历防护可被编码绕过** — `tool_registry.rs:78-95` 只做字符串 `..` 检测 + 小写化,URL 编码 `%2e%2e` / Unicode 同形字符可绕过。**修法**:先 `percent_decode` 再做词法 `..` 分段归一化检查;对不存在路径(new file) 也应规范化校验。— src-tauri/src/commands/ai/tool_registry.rs:78-95 #### 🟡 P1 — 高概率问题(5 项·建议本轮修复) - [ ] **BUG-260617-04 [P1]** — **`ai_chat_force_send` → `ai_chat_send` 双锁竞态窗口** — `commands.rs:763` force_send 先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock。两锁之间 stop IPC 可插入打断"原子复位+发送"语义;若 ai_chat_send 因 generating=true 被 reject → 前端状态不一致。**修法**:force_send 内联 spawn run_agentic_loop(同 regenerate/edit 模式),不走 ai_chat_send 门控。— src-tauri/src/commands/ai/commands.rs:744-763 -- [ ] **BUG-260617-05 [P1]** — **`try_continue_agent_loop` 4 次独立 lock 非原子化** — `agentic.rs:943-1036` 至少 4 次 `state.ai_session.lock().await`,每次 release 后其他 IPC 可修改 session → 续跑判断基于过时快照(如步骤 1 判 should_continue=true → 步骤 2 间 user 点 stop → 步骤 3 仍续跑)。**修法**:单次 lock 内完成所有字段读写,或引入结构化快照一次取出。— src-tauri/src/commands/ai/agentic.rs:943-1036 -- [ ] **BUG-260617-06 [P1]** — **`accumulate_tokens` 整数溢出无 saturating 保护** — `conversation.rs:49-51` `old.unwrap_or(0) + add as i64` 长期对话累积接近 i64::MAX 后翻负。**修法**:改用 `.saturating_add()` 或类型改为 `u64`。— src-tauri/src/commands/ai/conversation.rs:49-51 -- [ ] **BUG-260617-07 [P1]** — **`generate_diff` LCS O(n*m) 内存爆炸** — `tool_registry.rs:32-75` 标准 DP diff,5000 行输入 ≈ 200MB DP 表。虽有 changes>300 截断但截断前已分配计算完毕。**修法**:超长输入(均>1000行)跳过 LCS 改用 Myers diff 或直接返回截断 diff。— src-tauri/src/commands/ai/tool_registry.rs:32-75 -- [ ] **BUG-260617-08 [P1]** — **Lagged 循环每次重建 WorkflowRepo 连接池** — `workflow.rs:204` forward 循环内每次 `WorkflowRepo::new(&forward_db)`,高频 Lagged 场景可能连接数暴增。**修法**:Repo 提到循环外创建一次或传 Arc。— src-tauri/src/commands/ai/workflow.rs:204 +- [x] **BUG-260617-05 [P1]** — **`try_continue_agent_loop` 4 次独立 lock 非原子化** — `agentic.rs:943-1036` 至少 4 次 `state.ai_session.lock().await`,每次 release 后其他 IPC 可修改 session → 续跑判断基于过时快照(如步骤 1 判 should_continue=true → 步骤 2 间 user 点 stop → 步骤 3 仍续跑)。**修法**:单次 lock 内完成所有字段读写,或引入结构化快照一次取出。— src-tauri/src/commands/ai/agentic.rs:943-1036 +- [x] **BUG-260617-06 [P1]** — **`accumulate_tokens` 整数溢出无 saturating 保护** — `conversation.rs:49-51` `old.unwrap_or(0) + add as i64` 长期对话累积接近 i64::MAX 后翻负。**修法**:改用 `.saturating_add()` 或类型改为 `u64`。— src-tauri/src/commands/ai/conversation.rs:49-51 +- [x] **BUG-260617-07 [P1]** — **`generate_diff` LCS O(n*m) 内存爆炸** — `tool_registry.rs:32-75` 标准 DP diff,5000 行输入 ≈ 200MB DP 表。虽有 changes>300 截断但截断前已分配计算完毕。**修法**:超长输入(均>1000行)跳过 LCS 改用 Myers diff 或直接返回截断 diff。— src-tauri/src/commands/ai/tool_registry.rs:32-75 +- [x] **BUG-260617-08 [P1]** — **Lagged 循环每次重建 WorkflowRepo 连接池** — `workflow.rs:204` forward 循环内每次 `WorkflowRepo::new(&forward_db)`,高频 Lagged 场景可能连接数暴增。**修法**:Repo 提到循环外创建一次或传 Arc。— src-tauri/src/commands/ai/workflow.rs:204 #### ⚪ P2 — 关注项(3 项·低优先级) -- [ ] **BUG-260617-09 [P2]** — **递归深度 max_depth 由 LLM 参数控制无上限** — `tool_registry.rs:1391+/1511+` LLM 可传入极大值,虽有 entries 上限隐式约束但防御不完整。**修法**:clamp 到合理范围(如 1-10)。— src-tauri/src/commands/ai/tool_registry.rs:1391-1545 -- [ ] **BUG-260617-10 [P2]** — **skills.rs OnceLock 初始化用同步 std::fs 阻塞 runtime** — `skills.rs:69,168` 首次 skills 查询阻塞 tokio runtime 数十ms。影响极小(仅首次),但与规范偏差。**修法**:`spawn_blocking` 包裹或接受当前行为。— src-tauri/src/commands/ai/skills.rs:69,168 -- [ ] **BUG-260617-11 [P2]** — **`read_file` 工具无 offset 时全量返回大文件** — `tool_registry.rs:787-794` 无 offset 时 `content.clone()` 全量返回(1865 行/101KB 的 tool_registry.rs 自身即触发此问题)。**修法**:无 offset 默认返回 500 行 + has_more 提示翻页。关联 memory:[[read-file-pagination-needed]](已记录方案)。— src-tauri/src/commands/ai/tool_registry.rs:787-794 +- [x] **BUG-260617-09 [P2]** — **递归深度 max_depth 由 LLM 参数控制无上限** — `tool_registry.rs:1391+/1511+` LLM 可传入极大值,虽有 entries 上限隐式约束但防御不完整。**修法**:clamp 到合理范围(如 1-10)。— src-tauri/src/commands/ai/tool_registry.rs:1391-1545 +- [x] **BUG-260617-10 [P2]** — **skills.rs OnceLock 初始化用同步 std::fs 阻塞 runtime** — `skills.rs:69,168` 首次 skills 查询阻塞 tokio runtime 数十ms。影响极小(仅首次),但与规范偏差。**修法**:`spawn_blocking` 包裹或接受当前行为。— src-tauri/src/commands/ai/skills.rs:69,168 +- [x] **BUG-260617-11 [P2]** — **`read_file` 工具无 offset 时全量返回大文件** — `tool_registry.rs:787-794` 无 offset 时 `content.clone()` 全量返回(1865 行/101KB 的 tool_registry.rs 自身即触发此问题)。**修法**:无 offset 默认返回 500 行 + has_more 提示翻页。关联 memory:[[read-file-pagination-needed]](已记录方案)。— src-tauri/src/commands/ai/tool_registry.rs:787-794 --- @@ -1117,9 +1117,9 @@ gap: 8px; #### 🔴 已确认交互 Bug - [ ] **UX-260617-01 [P0]🎯** — **消息/工具卡大量重叠堆叠(用户实测确认)**。5 角度深入分析(todo.md L116-180+):CSS 声明链路正常、scoped 无冲突、DOM 结构正确 → 排除常规原因。**核心嫌疑**:虚拟滚动 IO 半激活态不一致——部分 item 被 IO 管理(可卸载)、部分不被管理(永远渲染) + height=0 时 minHeight 未设置 → slot 塌 0 → 重叠。**需复现定位确切触发路径后修复**。— src/components/AiChat.vue + src/composables/ai/useAiVirtualScroll.ts -- [ ] **UX-260617-02 [P1]🎯** — **CR-59 审查发现:`onPoolWeightChange` 缺乐观更新 revert 逻辑** — Settings.vue:643-656 weight 输入 debounce 300ms 调 IPC,成功后未像 onPoolToggle 一样 revert 到服务端值。IPC 失败或延迟时 UI 显示旧值而非回滚。**修法**:补 revert 分支(对齐 onPoolToggle 模式)。— src/views/Settings.vue:643-656 -- [ ] **UX-260617-03 [P1]🎯** — **copyMsgContent 失败误报「已复制」(已记录未修)** — `AiChat.vue:1037-1040` clipboard.writeText 权限拒绝 → catch 仍 showToast('copied')。用户见"已复制"去粘贴发现空。**修法**:catch 改失败文案或静默。(已在 AI 链路审查 B-260615-46 记录,提升优先级至此区)。— src/components/AiChat.vue:1037-1040 -- [ ] **UX-260617-04 [P1]🎯** — **offsetToDOMPosition 恒返回 root 致选区恢复错位(已记录未修)** — `AiChat.vue:908-921` createTreeWalker 后 currentNode 初始=root 非 Text → ?? 恒取左 → 循环无 nextNode 推进 → 选区恒错位。UX-2025-01 功能实际失效。(已在 B-260615-47 记录,提升优先级至此区)。— src/components/AiChat.vue:908-921 +- [x] **UX-260617-02 [P1]🎯** — **CR-59 审查发现:`onPoolWeightChange` 缺乐观更新 revert 逻辑** — Settings.vue:643-656 weight 输入 debounce 300ms 调 IPC,成功后未像 onPoolToggle 一样 revert 到服务端值。IPC 失败或延迟时 UI 显示旧值而非回滚。**修法**:补 revert 分支(对齐 onPoolToggle 模式)。— src/views/Settings.vue:643-656 +- [x] **UX-260617-03 [P1]🎯** — **copyMsgContent 失败误报「已复制」(已记录未修)** — `AiChat.vue:1037-1040` clipboard.writeText 权限拒绝 → catch 仍 showToast('copied')。用户见"已复制"去粘贴发现空。**修法**:catch 改失败文案或静默。(已在 AI 链路审查 B-260615-46 记录,提升优先级至此区)。— src/components/AiChat.vue:1037-1040 +- [x] **UX-260617-04 [P1]🎯** — **offsetToDOMPosition 恒返回 root 致选区恢复错位(已记录未修)** — `AiChat.vue:908-921` createTreeWalker 后 currentNode 初始=root 非 Text → ?? 恒取左 → 循环无 nextNode 推进 → 选区恒错位。UX-2025-01 功能实际失效。(已在 B-260615-47 记录,提升优先级至此区)。— src/components/AiChat.vue:908-921 #### 🟡 待扫描确认的交互问题(aichat agent 结果回填后补充/降级) @@ -1131,21 +1131,21 @@ gap: 8px; - [x] ~~**UX-260617-05 [P2]🎯** — *(待扫描确认→降级)* 流式渲染增量追加闪烁~~ ✅ **排除**:streamingBlocks rAF 节流 + scheduleStreamParse 双层机制完备,无 dup 风险(scan 未发现闪烁 bug,rAF 块级 memo + snap 短路兜底有效) - [x] ~~**UX-260617-06 [P2]🎯** — *(待扫描确认→升级)* wasNearBottom~~ → **合并入 UX-260617-21**(stopChat 竞态窗口,含 scroll 一致性) - [x] ~~**UX-260617-07 [P2]🎯** — *(待扫描确认→降级)* 审批顺序~~ ✅ **低风险**:审批按 tool_call_id 串行处理,无并行冲突(F-05 超时静默是更大问题) -- [ ] **UX-260617-08 [P1]🎯** — **switchConversation/loadConversations 异常静默吞没** — `useAiConversations.ts:36-44` catch 为空 + `:117-119` switchConversation catch 仅 `messages=[]` 无反馈。网络抖动/IPC 断开 → 对话列表突然变空/切换后空白用户不知原因。**修法**:catch 加 toast('loadConvFail','warning');loadConversations 失败保留旧列表+stale 标记。— src/composables/ai/useAiConversations.ts:36-44,117-119 -- [ ] **UX-260617-09 [P1]🎯** — **startListener 吞掉注册异常导致全量事件静默丢失** — `useAiEvents.ts:354-373` IIFE 内 `aiApi.onEvent(handleEvent)` 异常被完全吞没(无 console.error/toast/log)。所有聊天事件(文本/完成/审批)全丢 → UI 表现"发了消息永远不回"。**修法**:IIFE 内加 try/catch + console.error + throw 让外层 finally 清理。— src/composables/ai/useAiEvents.ts:354-373 -- [ ] **UX-260617-10 [P1]🎯** — **AiError 不清理 pendingApprovals,错误后残留审批卡** — `useAiEvents.ts:326-349` AiError case 做 clearStreamWatchdog/清queue/推错误气泡但**从未清 pendingApprovals**。出错时有 tool_call 处于 pending_approval → 残留可点击审批按钮。**修法**:加 `state.pendingApprovals=[]` + `clearAllApprovalTimers()`。— src/composables/ai/useAiEvents.ts:326-349 -- [ ] **UX-260617-11 [P1]🎯** — **modelOverride 跨对话泄漏** — `useAiSend.ts:67` 模块级 ref,注释明确要求"前端应同步清 null"但**无任何代码执行此清除**。switchConversation/newConversation 不触碰。对话A选模型覆盖→切对话B→override 静默生效。**修法**:switchConversation 末尾加 `modelOverride.value=null`。— src/composables/ai/useAiSend.ts:64-67 +- [x] **UX-260617-08 [P1]🎯** — **switchConversation/loadConversations 异常静默吞没** — `useAiConversations.ts:36-44` catch 为空 + `:117-119` switchConversation catch 仅 `messages=[]` 无反馈。网络抖动/IPC 断开 → 对话列表突然变空/切换后空白用户不知原因。**修法**:catch 加 toast('loadConvFail','warning');loadConversations 失败保留旧列表+stale 标记。— src/composables/ai/useAiConversations.ts:36-44,117-119 +- [x] **UX-260617-09 [P1]🎯** — **startListener 吞掉注册异常导致全量事件静默丢失** — `useAiEvents.ts:354-373` IIFE 内 `aiApi.onEvent(handleEvent)` 异常被完全吞没(无 console.error/toast/log)。所有聊天事件(文本/完成/审批)全丢 → UI 表现"发了消息永远不回"。**修法**:IIFE 内加 try/catch + console.error + throw 让外层 finally 清理。— src/composables/ai/useAiEvents.ts:354-373 +- [x] **UX-260617-10 [P1]🎯** — **AiError 不清理 pendingApprovals,错误后残留审批卡** — `useAiEvents.ts:326-349` AiError case 做 clearStreamWatchdog/清queue/推错误气泡但**从未清 pendingApprovals**。出错时有 tool_call 处于 pending_approval → 残留可点击审批按钮。**修法**:加 `state.pendingApprovals=[]` + `clearAllApprovalTimers()`。— src/composables/ai/useAiEvents.ts:326-349 +- [x] **UX-260617-11 [P1]🎯** — **modelOverride 跨对话泄漏** — `useAiSend.ts:67` 模块级 ref,注释明确要求"前端应同步清 null"但**无任何代码执行此清除**。switchConversation/newConversation 不触碰。对话A选模型覆盖→切对话B→override 静默生效。**修法**:switchConversation 末尾加 `modelOverride.value=null`。— src/composables/ai/useAiSend.ts:64-67 - [x] ~~**UX-260617-12 [P3]🎯** — *(待扫描确认→升级)* generating crash 兜底~~ → **合并入 UX-260617-01**(消息重叠根因含状态不一致) #### 🟡 aichat 扫描新增发现(MEDIUM · 8 项) - [ ] **UX-260617-13 [P1]🎯** — **刷新后主面板无流式恢复,用户看到"假死"** — 分离窗口(resumeInDetached)有完整恢复链(ai_is_generating→恢复streaming→推送占位气泡→localStorage currentText),**主面板完全没有等价逻辑**。刷新后 streaming=false + 无占位气泡 + 后端事件到达不进流式分支 → 文本仅 AiCompleted 时一次性显示退化为非流式体验。**修法**:主面板 onMounted/client-ready 后调 ai_is_generating IPC,true 则对齐 resumeInDetached 恢复链。工作量较大但用户感知最强。— src/composables/ai/useAiEvents.ts:155-179 + src/composables/ai/useAiWindow.ts:64-89 -- [ ] **UX-260617-14 [P2]🎯** — **审批超时 130s 静默复位无反馈** — `ToolCard.vue:507,541-54` 超时仅 `approving.value=false`,无 toast/error/日志。用户无法区分"审批完成状态没更新"和"超时复位"。**修法**:超时时 warning toast + APPROVE_LOADING_TIMEOUT_MS 改为共享常量导入。— src/components/ToolCard.vue:507,541-544 -- [ ] **UX-260617-15 [P2]🎯** — **regenerate/editMessage 丢弃图片 parts** — `useAiSend.ts:148-190` regenerate/edit 均无 parts 参数。含图片消息重生成/编辑后图片消失。**修法**:扩展 regenerate/editMessage IPC 签名透传 parts;或至少 UI 层对含 parts 消息禁用按钮/confirmDialog 提示。— src/composables/ai/useAiSend.ts:148-190 +- [x] **UX-260617-14 [P2]🎯** — **审批超时 130s 静默复位无反馈** — `ToolCard.vue:507,541-54` 超时仅 `approving.value=false`,无 toast/error/日志。用户无法区分"审批完成状态没更新"和"超时复位"。**修法**:超时时 warning toast + APPROVE_LOADING_TIMEOUT_MS 改为共享常量导入。— src/components/ToolCard.vue:507,541-544 +- [x] **UX-260617-15 [P2]🎯** — **regenerate/editMessage 丢弃图片 parts** — `useAiSend.ts:148-190` regenerate/edit 均无 parts 参数。含图片消息重生成/编辑后图片消失。**修法**:扩展 regenerate/editMessage IPC 签名透传 parts;或至少 UI 层对含 parts 消息禁用按钮/confirmDialog 提示。— src/composables/ai/useAiSend.ts:148-190 - [ ] **UX-260617-16 [P2]🎯** — **无路由离开/关闭保护,未发送输入静默丢失** — AiChat.vue 缺 beforeRouteLeave/onBeforeUnload 守卫。输入框有内容/已粘贴图片时切页面 → 内容瞬间消失无提示。**修法**:beforeRouteLeave guard + detached window beforeunload 检查 inputText/pendingImages/pendingSkill 非空时 confirmDialog。— src/components/AiChat.vue (缺失守卫) -- [ ] **UX-260617-17 [P2]🎯** — **ToolCardList 分组名硬编码中文绕过 i18n** — `ToolCardList.vue:259-274` nameMap 全硬编码中文(`'读取文件'`等)。英文 locale 下分组标题仍显示中文。**修法**:value 替换 i18n key(`ai.toolGroup.readFile`)。— src/components/ToolCardList.vue:259-274 -- [ ] **UX-260617-18 [P2]🎯** — **isToolFailure() 正则过于宽泛误判合法输出为失败** — `ToolCard.vue:220-224` `/执行失败|failed|error[:\s]/i` 匹配任何含这些子串的文本。search_files 结果列 error.log / run_command stderr 含 warning 均被标红。**修法**:收窄为 `/^(执行失败|Error:|Failed:)/m` 或按工具类型分别配置检测策略。— src/components/ToolCard.vue:220-224 -- [ ] **UX-260617-19 [P2]🎯** — **stopChat 本地先行 reset 与后端竞态窗口** — `useAiSend.ts:479-488` 先设 streaming=false 再发 IPC stop。IPC 失败时后端继续生成但前端已停止;若后续事件也丢失则永久卡死。注释承认风险但无缓解。**修法**:stop 后启动 5s 守护超时,未收到 Completed/Error 则推提示或重试 stop。— src/composables/ai/useAiSend.ts:479-488 +- [x] **UX-260617-17 [P2]🎯** — **ToolCardList 分组名硬编码中文绕过 i18n** — `ToolCardList.vue:259-274` nameMap 全硬编码中文(`'读取文件'`等)。英文 locale 下分组标题仍显示中文。**修法**:value 替换 i18n key(`ai.toolGroup.readFile`)。— src/components/ToolCardList.vue:259-274 +- [x] **UX-260617-18 [P2]🎯** — **isToolFailure() 正则过于宽泛误判合法输出为失败** — `ToolCard.vue:220-224` `/执行失败|failed|error[:\s]/i` 匹配任何含这些子串的文本。search_files 结果列 error.log / run_command stderr 含 warning 均被标红。**修法**:收窄为 `/^(执行失败|Error:|Failed:)/m` 或按工具类型分别配置检测策略。— src/components/ToolCard.vue:220-224 +- [x] **UX-260617-19 [P2]🎯** — **stopChat 本地先行 reset 与后端竞态窗口** — `useAiSend.ts:479-488` 先设 streaming=false 再发 IPC stop。IPC 失败时后端继续生成但前端已停止;若后续事件也丢失则永久卡死。注释承认风险但无缓解。**修法**:stop 后启动 5s 守护超时,未收到 Completed/Error 则推提示或重试 stop。— src/composables/ai/useAiSend.ts:479-488 #### ⚪ aichat 扫描新增发现(LOW · 8 项) @@ -1155,9 +1155,14 @@ gap: 8px; - [ ] **UX-260617-23 [P3]🎯** — **搜索结果选中不清除搜索框,搜索视图不退出** — `AiChat.vue:46` 点击结果 switchConversation 但不清 searchQuery。搜索文字保留+仍显示扁平列表。**修法**:点击 handler 加 `searchQuery=''` 自动退出搜索模式。— src/components/AiChat.vue:46 - [ ] **UX-260617-24 [P3]🎯** — **删除当前活跃对话无特殊警告** — `AiChat.vue:1140-1145` confirmDelete 通用文案不区分活跃对话。对比 confirmNewConversation 有上下文感知警告。**修法**:id===activeConversationId 时改醒目提示说明消息区将清空。— src/components/AiChat.vue:1140-1145 - [ ] **UX-260617-25 [P3]🎯** — **parseBlockNoCache 非 null 断言无运行时防护** — `AiChat.vue:848` `purify!.sanitize(marked!.parse(...))` 。marked/DOMPurify 动态 import 失败时 TypeError → rAF 循环中断流式卡死。**修法**:防御性 null check + 降级 escapeFallback。— src/components/AiChat.vue:848 -- [ ] **UX-260617-26 [P3]🎯** — **startListener/stopListener 竞态:_startPromise 未在 stop 中清零** — `useAiEvents.ts:354+378` stop 不清 _startPromise。极速 mount/unmount/mount(HMR) → 返回过期 promise。**修法**:stop 中加 `_startPromise=null`。— src/composables/ai/useAiEvents.ts:378-388 +- [x] **UX-260617-26 [P3]🎯** — **startListener/stopListener 竞态:_startPromise 未在 stop 中清零** — `useAiEvents.ts:354+378` stop 不清 _startPromise。极速 mount/unmount/mount(HMR) → 返回过期 promise。**修法**:stop 中加 `_startPromise=null`。— src/composables/ai/useAiEvents.ts:378-388 - [ ] **UX-260617-27 [P3]🎯** — **ConfirmDialog 危险按钮默认标签"删除"语义不安全** — `ConfirmDialog.vue:9-10` 默认 `$t('common.delete')`。非删除场景(高危工具审批)忘传 dangerLabel → 按钮显示"删除"。**修法**:默认值改为 `$t('common.confirm')`。— src/components/ConfirmDialog.vue:9-10 +> **[销账 wf wys30viqi·2026-06-17]** 8 agent 文件域隔离核验修复(独立 grep 核验源码形态 + cargo check --workspace EXIT 0 + vue-tsc EXIT 0)。 +> - **已修 15**:BUG-02 file_info 流式读 .take(8192) / 03 validate_path percent_decode 词法归一化(+percent-encoding 依赖) / 05 try_continue 5lock→ContinueSnapshot 快照收敛+spawn 前重检 / 06 saturating_add(含 ContextUsage 周边) / 07 LCS_MAX_LINES=1000 双>1000 跳 DP / 09 max_depth clamp(1,10) / 11 read_file 默认 500 行+has_more;UX-08 catch toast+保留旧列表 / 09 startListener try/catch+console.error / 10 AiError clearAllApprovalTimers+清 pendingApprovals / 14 STREAM_TIMEOUT_MS 共享常量+reactive toast / 17 toolGroup i18n 14key / 18 isToolFailure 正则收窄^行首锚 / 19 stopChat try/catch+watchdog 复用 / 26 stopListener _startPromise=null。 +> - **误判销账 6**(审查报告 file:line 漂移/已修未销账,独立核验拦下未引入防御性反模式):BUG-08 WorkflowRepo 是 Arc clone 非连接池(crud.rs:81 new 仅 db.conn() clone,workflow.rs:292 注释已正确) / UX-02 ProviderPanel.vue:318-334 已有 revert 三分支 / UX-03 catch 已 fallthrough+execCommand 布尔分流 copyFailed / UX-04 walker.nextNode 已正确推进(旧 ?? nextNode 模式已不存在) / UX-11 AiChat.vue:1234 watch activeConversationId 已清 modelOverride(非 useAiSend.ts) / UX-15 regenerate 只 pop AI 气泡+editMessage 只换 content,前后端均不丢 parts。 +> - **留观察 1**:BUG-10 skills OnceLock 首次阻塞 tokio worker 数十ms 非热点,正确修法 skills_cached()→async+spawn_blocking 跨 read_skill_content/ai_list_skills 签名超本批文件域边界,接受当前行为。 + #### ℹ️ 架构观察(INFO · 2 项·不进修复队列) - [ ] **UX-260617-28 [INFO]** — **双监听器同通道 fragility** — useAiEvents + useAiContext 各自 listen('ai-chat-event'),人工协调防双重处理(AiCompressing flag)非架构保证。未来新增事件处理可能触发双重 bug。长期考虑单一分发器模式。— src/composables/ai/useAiEvents.ts:269 + src/composables/ai/useAiContext.ts:85-105 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1a86c92..d7087be 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,8 @@ df-project = { path = "../crates/df-project" } futures = "0.3" # write_file base64 编码:写二进制/非 UTF-8 文件(图片/PDF/Excel 等) base64 = "0.22" +# validate_path URL 解码:防 %2e%2e 等 URL 编码绕过路径遍历检查(BUG-260617-03) +percent-encoding = "2" # keyring:密钥解析下沉到 df-storage(workspace 统一声明平台 feature), # src-tauri 经 workspace 引用(转发壳 build_provider_for 不直接碰 keyring,但旧路径/兼容保留)。 # 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md diff --git a/src-tauri/src/commands/ai/agentic.rs b/src-tauri/src/commands/ai/agentic.rs index 5c1dca1..b4ac4d7 100644 --- a/src-tauri/src/commands/ai/agentic.rs +++ b/src-tauri/src/commands/ai/agentic.rs @@ -941,21 +941,32 @@ pub(crate) async fn run_agentic_loop( /// 分支:审批已被 remove,改为以剩余 pending_approvals 任一 conversation_id 做一致性校验 /// (此处空,校验通过即沿用全局值,该期 generating=true 且 switch 为 readonly 不并发)。 pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState, start_iteration: usize) { - let (is_generating, has_pending, pending_conv_id) = { + // BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、 + // spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。 + // 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子 + // 重检 generating 仍为 true 才续跑,stop 后中途插入的直接收敛退出。 + let snap = { let session = state.ai_session.lock().await; // pending_approvals 中任一审批的 conversation_id:审批等待态(has_pending)下作为 conv_id 来源, // 取第一个非空值(同一对话的审批 conversation_id 一致,见 process_tool_calls 写入路径)。 let pending_conv_id = session.pending_approvals.values() .find_map(|a| a.conversation_id.clone()); - (session.generating, !session.pending_approvals.is_empty(), pending_conv_id) + ContinueSnapshot { + is_generating: session.generating, + has_pending: !session.pending_approvals.is_empty(), + pending_conv_id, + active_conversation_id: session.active_conversation_id.clone(), + agent_language: session.agent_language.clone(), + model_override: session.model_override.clone(), + } }; - let should_continue = is_generating && !has_pending; + let should_continue = snap.is_generating && !snap.has_pending; if !should_continue { // generating=false(被 stop)或仍有审批(pending_approvals 非空): // 统一 emit AiCompleted 标当前轮收敛,清前端 streaming。 // 轮 token 已在前序 AiCompleted/AiApprovalResult 流程落库,此处零 token 上报仅作收敛信号。 - if is_generating { + if snap.is_generating { // pending_approvals 非空但 generating 仍 true:转审批态,前端审批态 watchdog 已 clear,不卡 tracing::info!("[ai] try_continue 跳过:仍有待审批,转审批等待态"); } else { @@ -963,12 +974,9 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState, s tracing::info!("[ai] try_continue 跳过:generating 已复位(被 stop/已结束),补发 AiCompleted 清前端 streaming"); // R-PD-6: 优先用审批所属 conversation_id(审批等待态被 stop 触发,审批仍在 pending_approvals), // 仅当无任何审批(has_pending=false 且 generating=false)时回退 active_conversation_id。 - let conv_id = match pending_conv_id { + let conv_id = match snap.pending_conv_id.clone() { Some(cid) => cid, - None => { - let session = state.ai_session.lock().await; - session.active_conversation_id.clone().unwrap_or_default() - } + None => snap.active_conversation_id.clone().unwrap_or_default(), }; let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, @@ -1009,13 +1017,9 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState, s }; // R-PD-6: 续生成路径 conv_id 解耦——has_pending=false 时审批已 remove,无审批 conversation_id 可取; // 此期 generating=true 且 switchConversation 为 readonly 不并发改 active_conversation_id, - // 故读全局值安全(非竞态期);若 has_pending=true 已在上面 return,不会到此。 - let (lang, conv_id) = { - let session = state.ai_session.lock().await; - let lang = session.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string()); - let conv_id = session.active_conversation_id.clone().unwrap_or_default(); - (lang, conv_id) - }; + // 故读快照值安全(非竞态期);若 has_pending=true 已在上面 return,不会到此。 + let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string()); + let conv_id = snap.active_conversation_id.clone().unwrap_or_default(); let system_prompt = build_system_prompt(state, &lang).await; let session_arc = state.ai_session.clone(); @@ -1029,10 +1033,22 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState, s // F-260616-07: 流式失败重试次数快照 let max_retries = state.agent_max_retries.load(Ordering::SeqCst); // F-01 阶段6: 续跑沿用同一主对话的 model_override(审批续跑/达 max 续跑保持一致)。 - let model_override = { - let session = state.ai_session.lock().await; - session.model_override.clone() - }; + let model_override = snap.model_override.clone(); + + // BUG-260617-05 续: provider 解析/build_system_prompt 期间用户可能点 stop。 + // spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。 + // (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。) + if !state.ai_session.lock().await.generating { + tracing::info!("[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted"); + let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { + total_tokens: 0, + prompt_tokens: 0, + completion_tokens: 0, + incomplete: None, + conversation_id: Some(conv_id.clone()), + }); + return; + } // 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本 // 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开 @@ -1045,3 +1061,15 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState, s run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await; }); } + +/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。 +/// 单次 lock 取出后无锁态判定,消除多 lock 间其他 IPC(ai_chat_stop/clear/switch)改写 session +/// 致续跑判断基于过时快照的 TOCTOU 竞态。 +struct ContinueSnapshot { + is_generating: bool, + has_pending: bool, + pending_conv_id: Option, + active_conversation_id: Option, + agent_language: Option, + model_override: Option, +} diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index 358dec0..ee37976 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -46,8 +46,10 @@ impl TokenAccumulator { /// 把单轮增量叠加到 DB 的 Option 字段(读旧值+增量,跨 loop 实例防覆盖) /// /// 纯函数:抽自 save_conversation 的 token 累加逻辑,None 起始当作 0。 +/// saturating_add:长期对话累积接近 i64::MAX 时不再翻负,封顶在 i64::MAX(统计语义安全, +/// 溢出回绕成负值会污染前端用量展示与计费/限额判定)。 pub(crate) fn accumulate_tokens(old: Option, add: u32) -> Option { - Some(old.unwrap_or(0) + add as i64) + Some(old.unwrap_or(0).saturating_add(add as i64)) } /// 持久化截断阈值:超过此长度的消息 content 落库前截断头尾各保 HEAD/TAIL 字符。 diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 737a2c6..2e81e32 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -29,12 +29,36 @@ const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60; /// /// AE-2025-03(路径 B):audit.rs 挂起审批前预读旧文件复用此函数生成 diff, /// 供前端审批卡即时预览(write_file 审批不再只看裸 content)。故 pub(crate)。 +/// +/// BUG-260617-07: LCS DP 表 O(n*m) 内存,两文件均>1000 行时 DP 表可达数百 MB +/// (5000×5000×8B≈200MB)。虽然 1MB 字节上限能挡住多数情况,但短行高密度的源码 +/// (大量空行/单字符行)仍可能突破。改:两文件均>1000 行时跳过 LCS,退化为 +/// 「删旧全量 + 增新全量」朴素行对比 + 截断提示。朴素对比 O(n+m) 内存,安全。 +/// (patch_file 调用方传入的 old/new 是局部替换,通常远小于全文,正常路径仍走 LCS) pub(crate) fn generate_diff(old: &str, new: &str) -> String { let a: Vec<&str> = old.lines().collect(); let b: Vec<&str> = new.lines().collect(); let (n, m) = (a.len(), b.len()); - // LCS 动态规划表(usize 即可;大文件已被 1MB 限制挡住,行数有限) + // 超长输入降级:两文件均>1000 行跳过 LCS DP(防 200MB+ 内存峰值), + // 退化为朴素全删全增 diff(配 300 行截断),足够审批卡看出"大范围改动"语义。 + const LCS_MAX_LINES: usize = 1000; + if n > LCS_MAX_LINES && m > LCS_MAX_LINES { + let mut out = String::new(); + let mut changes = 0usize; + for line in &a { out.push_str("-"); out.push_str(line); out.push('\n'); changes += 1; } + for line in &b { out.push_str("+"); out.push_str(line); out.push('\n'); changes += 1; } + if changes > 300 { + let kept: String = out.lines().take(300).collect::>().join("\n"); + return format!( + "{}\n... (输入过长({}/{} 行)已跳过 LCS 退化对比,diff 已截断,共 {} 处变更行)", + kept, n, m, changes + ); + } + return out.trim_end_matches('\n').to_string() + "\n"; + } + + // LCS 动态规划表(usize 即可;已由 LCS_MAX_LINES 挡住超大输入,行数有限) let mut dp = vec![vec![0usize; m + 1]; n + 1]; for i in (0..n).rev() { for j in (0..m).rev() { @@ -74,14 +98,35 @@ pub(crate) fn generate_diff(old: &str, new: &str) -> String { out.trim_end_matches('\n').to_string() + "\n" } -/// 验证文件路径:禁止访问系统敏感目录 +/// 验证文件路径:禁止访问系统敏感目录 + 防路径遍历绕过 +/// +/// 安全要点(BUG-260617-03): +/// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。 +/// LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过, +/// 解码后再走分段归一化校验。 +/// 2. **按路径分隔符分段**做 `..` 归一化检测——纯子串 `contains("..")` 会误伤 +/// 合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别 +/// 解码后 `..%2f` / `..\` 等变体。 +/// 3. 敏感系统目录黑名单仍保留(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32)。 fn validate_path(path: &str) -> anyhow::Result<()> { - // 规范化为反斜杠:LLM 可能传正斜杠绕过黑名单(Windows tokio::fs 两种分隔符都吃) - let normalized = path.replace('/', "\\"); + // 1) URL 解码:防 %2e%2e / %2f / %5c 等 URL 编码绕过 (LLM/外部输入可能传编码串) + // percent_decode_str 对非法 %XX 容错(保留原字节),decode_utf8_lossy 容错非 UTF-8。 + use percent_encoding::percent_decode_str; + let decoded = percent_decode_str(path).decode_utf8_lossy().into_owned(); + + // 2) 规范化为反斜杠:LLM 可能传正斜杠绕过黑名单(Windows tokio::fs 两种分隔符都吃) + let normalized = decoded.replace('/', "\\"); let lower = normalized.to_lowercase(); - if lower.contains("..") { + + // 3) 分段归一化检查:按分隔符切分,任何一段 == ".." 视为路径遍历。 + // 比纯 contains("..") 更精准(不误伤 my..file 这类合法名),且能识别解码后的 `..`。 + let has_traversal = lower + .split(|c| c == '\\' || c == '/') + .any(|seg| seg == ".."); + if has_traversal { anyhow::bail!("禁止路径遍历 (..)"); } + if lower.contains("\\.ssh") || lower.contains("\\.aws") || lower.contains("\\.gnupg") @@ -791,16 +836,30 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { })); } // 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行) - let result = if let Some(offset) = args["offset"].as_u64() { + // BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件, + // 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。 + // 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。 + let line_count = content.lines().count(); + let (result, offset_used, has_more) = if let Some(offset) = args["offset"].as_u64() { let lines: Vec<&str> = content.lines().collect(); let skip = offset as usize; let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize; - lines.into_iter().skip(skip).take(limit).collect::>().join("\n") + let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect(); + let more = (skip + page.len()) < line_count; + (page.join("\n"), Some(skip), more) } else { - content.clone() + // 无 offset 默认前 500 行(大文件翻页友好,避免一次灌入全量) + const DEFAULT_PREVIEW_LINES: usize = 500; + let page: Vec<&str> = content.lines().take(DEFAULT_PREVIEW_LINES).collect(); + let more = line_count > page.len(); + (page.join("\n"), None, more) }; - let line_count = content.lines().count(); - Ok(serde_json::json!({ "path": path, "content": result, "size": metadata.len(), "lines": line_count })) + Ok(serde_json::json!({ + "path": path, "content": result, "size": metadata.len(), "lines": line_count, + "offset": offset_used, + "returned_lines": result.lines().count(), + "has_more": has_more, + })) })), ); registry.register( @@ -814,7 +873,9 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let recursive = args["recursive"].as_bool().unwrap_or(false); let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); - let max_depth = args["max_depth"].as_u64().unwrap_or(3) as usize; + // BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000) + // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。 + let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize; let mut entries = Vec::new(); let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?; Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated })) @@ -1120,10 +1181,25 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { let modified = metadata.modified() .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as i64); - // is_binary: 读前 8KB 检测 \x00 + // is_binary: 流式读前 8KB 检测 \x00 (BUG-260617-02) + // 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192, + // >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。 + // 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测, + // size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。 let is_binary = if !is_dir && size > 0 { - let sample = tokio::fs::read(path).await.unwrap_or_default(); - sample[..sample.len().min(8192)].contains(&0x00) + use tokio::io::AsyncReadExt; + let file = match tokio::fs::File::open(path).await { + Ok(f) => f, + Err(_) => return Ok(serde_json::json!({ + "path": path, "exists": true, "size": size, "lines": serde_json::Value::Null, + "modified": modified, "is_binary": false, "is_dir": is_dir, + "error": "读取文件头失败" + })), + }; + let mut reader = tokio::io::BufReader::new(file); + let mut sample = vec![0u8; 8192]; + let n = reader.read(&mut sample).await.unwrap_or(0); + sample[..n].contains(&0x00) } else { false }; // lines: 文本文件 \n 计数(>2MB 跳过避免全量读) let lines = if !is_dir && !is_binary && size <= 2_097_152 { diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index 0b152ef..686885f 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -128,6 +128,11 @@ :danger-label="t('common.confirm')" @result="answerConfirm" /> + + + +
{{ approveToast.msg }}
+
@@ -202,7 +207,8 @@ function shouldKeepOpen(tc: AiToolCallInfo): boolean { * - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败 * - 通用工具失败:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."), * 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号 - * 此时按字符串前缀 "执行失败"/"failed" 启发式判定(对齐后端 err_msg 格式)。 + * 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式, + * 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。 * * 注意:status==='rejected'(用户拒绝)走独立分支,不算工具失败,不进此函数。 * running/pending_approval 自然 false(无 result 或 result 未稳定)。 @@ -220,7 +226,10 @@ function isToolFailure(tc: AiToolCallInfo): boolean { if (!parseResult(tc.result)) { const raw = typeof tc.result === 'string' ? tc.result : '' if (!raw) return false - return /执行失败|failed|error[:\s]/i.test(raw) + // UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串, + // 误伤 search_files 命中 error.log / run_command stderr 含 "warning" 等正常产出)。 + // 仅当某行以「执行失败」(中文 err_msg 前缀)/「Error:」/「Failed:」开头才判失败。 + return /^(执行失败|Error:|Failed:)/m.test(raw) } return false } @@ -464,10 +473,11 @@ function formatFileInfoResult(r: ToolResult): string {