修复: AI工具OOM/路径遍历+续跑锁收敛+UX交互批

This commit is contained in:
2026-06-17 14:07:16 +08:00
parent 2065335c8c
commit 6b67214395
15 changed files with 390 additions and 98 deletions

1
Cargo.lock generated
View File

@@ -750,6 +750,7 @@ dependencies = [
"df-workflow",
"futures",
"keyring",
"percent-encoding",
"serde",
"serde_json",
"tauri",

View File

@@ -1090,22 +1090,22 @@ gap: 8px;
#### 🔴 P0 — 确定性 Bug3 项·建议立即修复)
- [ ] **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 diff5000 行输入 ≈ 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<WorkflowRepo>。— 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 diff5000 行输入 ≈ 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<WorkflowRepo>。— 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 未发现闪烁 bugrAF 块级 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 IPCtrue 则对齐 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_moreUX-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

View File

@@ -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

View File

@@ -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<String>,
active_conversation_id: Option<String>,
agent_language: Option<String>,
model_override: Option<String>,
}

View File

@@ -46,8 +46,10 @@ impl TokenAccumulator {
/// 把单轮增量叠加到 DB 的 Option<i64> 字段(读旧值+增量,跨 loop 实例防覆盖)
///
/// 纯函数:抽自 save_conversation 的 token 累加逻辑,None 起始当作 0。
/// saturating_add:长期对话累积接近 i64::MAX 时不再翻负,封顶在 i64::MAX(统计语义安全,
/// 溢出回绕成负值会污染前端用量展示与计费/限额判定)。
pub(crate) fn accumulate_tokens(old: Option<i64>, add: u32) -> Option<i64> {
Some(old.unwrap_or(0) + add as i64)
Some(old.unwrap_or(0).saturating_add(add as i64))
}
/// 持久化截断阈值:超过此长度的消息 content 落库前截断头尾各保 HEAD/TAIL 字符。

View File

@@ -29,12 +29,36 @@ const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
///
/// AE-2025-03路径 Baudit.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::<Vec<_>>().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<Database>) -> 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::<Vec<&str>>().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<Database>) -> 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<Database>) -> 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 {

View File

@@ -128,6 +128,11 @@
:danger-label="t('common.confirm')"
@result="answerConfirm"
/>
<!-- UX-260617-14:审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) -->
<Transition name="ai-tool-toast">
<div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div>
</Transition>
</div>
</template>
@@ -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 {
</script>
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { computed, ref, reactive, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
import ConfirmDialog from './ConfirmDialog.vue'
const { t } = useI18n()
@@ -504,10 +514,23 @@ const emit = defineEmits<{
* AiApprovalResult/approved 后 hang 在工具执行前),全局看门狗只回滚 running 不会触及本卡。
* 故此处加本地兜底计时(对齐全局看门狗 STREAM_TIMEOUT_MS),到点复位 loading 让用户能重试/改判。
*/
const APPROVE_LOADING_TIMEOUT_MS = 130000 // 对齐 useAiStream.STREAM_TIMEOUT_MS
// UX-260617-14:复用 useAiStream 共享常量(原硬编码 130000,单一来源防漂移)。
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
const approving = ref(false)
let approvingTimer: ReturnType<typeof setTimeout> | null = null
// ── 审批超时兜底 toast(零依赖,复用 AiChat/Settings 同款 reactive toast 模式) ──
// ToolCard 是子组件无根 toast 挂载点,且可能以分离窗口运行(App.vue 根 toast 不在 DOM),
// 故本卡自管一个最小 toast;仅在审批 loading 超时兜底时短暂提示。
const approveToast = reactive({ visible: false, msg: '' })
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
function showApproveTimeoutToast(): void {
approveToast.msg = t('aiTool.approveLoadingTimeout')
approveToast.visible = true
if (_approveToastTimer) clearTimeout(_approveToastTimer)
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
}
/**
* AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐)。
* AiToolCallInfo 无 risk 字段(风险仅存后端),故前端按工具名判定。新增 High 工具时同步加这里。
@@ -538,9 +561,12 @@ async function onApprove(approved: boolean) {
}
approving.value = true
// 本地兜底:到点强制复位 loading(后端无回执、卡片仍 pending_approval 时给用户重试入口)
// UX-260617-14:超时复位同时给 warning toast + 控制台日志,原仅静默 approving=false 用户无感知。
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: props.tc.id, approved })
}
@@ -554,6 +580,7 @@ watch(() => props.tc.status, (s) => {
// 组件卸载清计时器防泄漏
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
})
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
@@ -707,6 +734,8 @@ function toolResultSummary(tc: AiToolCallInfo): string {
overflow: hidden;
max-width: var(--df-msg-max-width);
animation: fadeInUp 0.2s var(--df-ease);
/* UX-260617-14:相对定位锚定本卡 toast(absolute 子元素) */
position: relative;
/* UX-260616-03: border/shadow/opacity 过渡让折叠/失败态切换不突兀。
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,
见 ToolCardList 注释);opacity 过渡覆盖自动收起时的视觉跳变(约 200ms)。 */
@@ -1174,4 +1203,27 @@ function toolResultSummary(tc: AiToolCallInfo): string {
white-space: pre-wrap;
word-break: break-all;
}
/* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
padding: 6px 12px;
border-radius: var(--df-radius-sm);
font-size: 11px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 90%;
overflow-wrap: anywhere;
pointer-events: none;
}
.ai-tool-toast--warning {
background: var(--df-warning-bg);
color: var(--df-warning);
border: 0.5px solid var(--df-warning);
}
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-tool-toast-enter-from, .ai-tool-toast-leave-to { opacity: 0; transform: translate(-50%, -6px); }
</style>

View File

@@ -45,8 +45,8 @@ import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue'
import type { AiToolCallInfo } from '@/api/types'
// useI18n needed for $t() in template (collapseAll / expandAll)
useI18n()
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
const { t } = useI18n()
const props = defineProps<{
/** 当前消息的工具调用列表 */
@@ -166,6 +166,34 @@ function toggleAllGroups(): void {
collapsedGroups.value = next
}
/**
* 分组显示名:工具名 → i18n 友好标签(英文化,原硬编码中文)。
* UX-260617-17:用 aiTool.toolGroup.* 子键查表;未命中的工具回退 _ → 空格风格。
*/
// tool.name → aiTool.toolGroup.* 子键(与后端 tool_registry 工具名一一对应)
const TOOL_GROUP_I18N_KEY: Record<string, string> = {
read_file: 'readFile',
write_file: 'writeFile',
list_directory: 'listDirectory',
create_task: 'createTask',
create_project: 'createProject',
create_idea: 'createIdea',
list_tasks: 'listTasks',
list_projects: 'listProjects',
list_ideas: 'listIdeas',
delete_project: 'deleteProject',
restore_project: 'restoreProject',
purge_project: 'purgeProject',
update_project: 'updateProject',
run_workflow: 'runWorkflow',
}
function groupDisplayName(group: ToolCallGroup): string {
const subKey = TOOL_GROUP_I18N_KEY[group.name]
if (subKey) return t(`aiTool.toolGroup.${subKey}`)
return group.name.replace(/_/g, ' ')
}
/**
* 单卡是否展开:
* - 分组未折叠 → 用户手动 expandedCards 或首卡默认展开
@@ -253,28 +281,6 @@ function groupIcon(name: string): string {
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
}
/** 分组显示名:工具名 + 数量提示 */
function groupDisplayName(group: ToolCallGroup): string {
const nameMap: Record<string, string> = {
read_file: '读取文件',
write_file: '写入文件',
list_directory: '列出目录',
create_task: '创建任务',
create_project: '创建项目',
create_idea: '创建灵感',
list_tasks: '列出任务',
list_projects: '列出项目',
list_ideas: '列出灵感',
delete_project: '删除项目',
restore_project: '恢复项目',
purge_project: '彻底删除项目',
update_project: '更新项目',
run_workflow: '运行工作流',
}
const label = nameMap[group.name] || group.name.replace(/_/g, ' ')
return label
}
</script>
<style scoped>

View File

@@ -9,10 +9,15 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { notifyConversationChanged } from './useAiEvents'
import { persistUiState } from './useAiPanel'
import type { AiToolCallInfo } from '@/api/types'
import { nextMsgId } from './aiShared'
import i18n from '@/i18n'
import type { AiMessage, AiToolCallInfo } from '@/api/types'
const appSettings = useAppSettingsStore()
// composable 无组件上下文,借全局 i18n 实例翻译错误文案(同 useAiSend/aiShared 模式)
const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>) => string).bind((i18n as any).global)
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */
async function loadConversations() {
try {
@@ -22,8 +27,21 @@ async function loadConversations() {
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
await switchConversation(pending)
}
} catch {
// 静默失败,不影响主流程
} catch (e) {
// UX-260617-08:网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。
// 此处保留旧列表(赋值语句抛出,旧 state.conversations 不被覆盖)+ 推错误气泡反馈。
// 仅首次加载(state.conversations 为空)时推气泡,避免拉取/侧栏刷新重试时反复弹错。
console.error('[AI] 加载会话列表失败:', e)
if (state.conversations.length === 0) {
state.messages.push({
id: `load-conv-fail-${nextMsgId()}`,
role: 'assistant',
content: t('ai.loadConvFail'),
isError: true,
timestamp: Date.now(),
} as AiMessage)
notifyConversationChanged()
}
}
}
@@ -114,8 +132,17 @@ export async function switchConversation(id: string) {
}
}),
}))
} catch {
state.messages = []
} catch (e) {
// UX-260617-08:历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
console.error('[AI] 切换对话历史消息解析失败:', e)
state.messages.push({
id: `switch-conv-fail-${nextMsgId()}`,
role: 'assistant',
content: t('ai.switchConvFail'),
isError: true,
timestamp: Date.now(),
} as AiMessage)
}
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值

View File

@@ -326,11 +326,15 @@ export function handleEvent(event: AiChatEvent) {
case 'AiError': {
clearStreamWatchdog()
clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
state.streaming = false
state.generatingConvId = null
state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
state.pendingApprovals = []
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
@@ -356,14 +360,23 @@ export async function startListener() {
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) return
// 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍
if (_startPromise) return _startPromise
// UX-260617-09: 内部任一 await reject 时,onMounted/sendMessage 的 await 在 Vue async
// 生命周期/void 调用点处成为未捕获 rejection(无 console 输出)→ listener 未注册且无任何提示,
// 表现为「发消息永远不回」。这里 catch + console.error 保留可观测性,再 throw 让外层
// finally 清 _startPromise(并发去重复位),调用方仍可 try/catch 兜底。
_startPromise = (async () => {
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
_unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
// AE-2025-06: onStreamTimeout(流式整流超时收尾)广播的清理事件 → 清全部审批超时计时器。
// useAiStream 不 import useAiSend(避循环依赖),经事件总线中转
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
// B-260616-01: L0 握手 — 通知后端前端已就绪,后端据此清除 HMR/刷新导致的残留 generating 状态
emit('ai-client-ready', { lastConvId: state.activeConversationId })
try {
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
_unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
// AE-2025-06: onStreamTimeout(流式整流超时收尾)广播的清理事件 → 清全部审批超时计时器
// useAiStream 不 import useAiSend(避循环依赖),经事件总线中转。
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
// B-260616-01: L0 握手 — 通知后端前端已就绪,后端据此清除 HMR/刷新导致的残留 generating 状态
emit('ai-client-ready', { lastConvId: state.activeConversationId })
} catch (err) {
console.error('[useAiEvents] startListener 注册失败,事件监听未生效:', err)
throw err
}
})()
try {
await _startPromise
@@ -382,6 +395,10 @@ function stopListener() {
_unlistenAiEvent = null
_unlistenConvChanged = null
_unlistenApprovalClear = null
// UX-260617-26: stop 清零去重 promise——极速 mount/unmount/mount(HMR)时,首 mount 的
// _startPromise 可能仍在 pending(未 await 完即 unmount),再 mount 若命中并发去重分支会
// 返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册。
_startPromise = null
clearStreamWatchdog()
clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件
clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件

View File

@@ -480,6 +480,13 @@ async function sendQueuedNow(index: number) {
/** 停止当前生成:本地先复位 streaming再发停止信号。
* 不依赖后端 AiCompleted 收尾——审批态看门狗已 clear(useAiEvents),若 AiCompleted 竞态丢失则 streaming 永久 true 卡死,故本地兜底。
*
* UX-260617-19: stop IPC 失败时的兜底。本地已先置 streaming=false但若 stop 信号未送达后端,
* 后端会继续生成并发回 delta/Completed 事件,与用户已感知的「已停止」语义冲突;更坏情况是
* 后端 hang 既不收尾也不发事件(stop_flag 未置位、无 watchdog 兜底则无恢复路径)。
* 故 stop 失败时不 clearStreamWatchdog反而重启看门狗后端若继续生成活跃 delta 会不断重置它
* 不误触发;后端若真 hang130s 后 onStreamTimeout 兜底复位 streaming+清队列+推错误气泡。
* 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。
*/
async function stopChat() {
state.streaming = false // 本地先复位,不依赖后端 AiCompleted审批态看门狗已 clear竞态丢失则卡死
@@ -487,7 +494,22 @@ async function stopChat() {
// UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
// 后端 agentic.rs:190 emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
// 当前未完成回复已入库(agentic.rs:185 save_conversation)保留为截断回复。
await aiApi.stopChat()
try {
await aiApi.stopChat()
} catch (e) {
// stop 信号未送达后端:重启看门狗(后端继续生成时活跃事件会重置它,真 hang 则 130s 后兜底收尾),
// 避免后端仍在跑却无任何恢复路径(原 clearStreamWatchdog 已把兜底关掉)。
resetStreamWatchdog()
const errMsg = e instanceof Error ? e.message : String(e)
state.messages.push({
id: `stop-err-${nextMsgId()}`,
role: 'assistant',
content: t('aiChat.stopLoopFailed', { msg: errMsg }),
isError: true,
timestamp: Date.now(),
} as AiMessage)
console.error('[AI] stopChat IPC 失败,已重启看门狗兜底:', e)
}
}
export function useAiSend() {

View File

@@ -38,6 +38,9 @@ export default {
approvalNotDelivered: 'Approval operation failed to reach the backend: {error}',
queuedSendFailed: 'Failed to send queued message: {error}',
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
// UX-260617-08: conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
switchConvFail: 'Failed to switch conversation. Please retry.',
// AE-2025-07: Agentic loop progress bar copy
// Note: max round / completed tool count are not surfaced by backend this round;
// template conditionally omits them. Add *WithMax keys once backend exposes them.

View File

@@ -82,6 +82,30 @@ export default {
// Tool result detail format (plain-text result + approval placeholder appends arg summary)
resultDetail: '{raw} ({detail})',
// UX-260617-17: ToolCardList group header (shown when 2+ same-name tools; was hardcoded zh)
toolGroup: {
readFile: 'Read File',
writeFile: 'Write File',
listDirectory: 'List Directory',
createTask: 'Create Task',
createProject: 'Create Project',
createIdea: 'Create Idea',
listTasks: 'List Tasks',
listProjects: 'List Projects',
listIdeas: 'List Ideas',
deleteProject: 'Delete Project',
restoreProject: 'Restore Project',
purgeProject: 'Purge Project',
updateProject: 'Update Project',
runWorkflow: 'Run Workflow',
},
// UX-260617-14: approval loading fallback timeout notice (no backend ack, card still pending_approval → reset + prompt)
approveLoadingTimeout: 'Approval response timed out, please retry or check the backend',
// UX-260617-18: tool failure detection copy (regex narrowed to line-anchored err_msg prefix)
// (reuse executionFailed/executionFailedHint, no new key)
// Approval argument key labels
argLabel: {
id: 'ID',

View File

@@ -38,6 +38,9 @@ export default {
approvalNotDelivered: '审批操作未送达后端:{error}',
queuedSendFailed: '队列消息发送失败:{error}',
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
// UX-260617-08:对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
switchConvFail: '对话切换失败,请重试',
// AE-2025-07: Agentic 循环进度条文案
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批',

View File

@@ -85,6 +85,30 @@ export default {
// 工具结果详情格式(非 JSON 纯文本 + 审批占位时补参数摘要)
resultDetail: '{raw}{detail}',
// UX-260617-17:ToolCardList 分组标题(同名工具 >=2 时显示;原硬编码中文,英文化)
toolGroup: {
readFile: '读取文件',
writeFile: '写入文件',
listDirectory: '列出目录',
createTask: '创建任务',
createProject: '创建项目',
createIdea: '创建灵感',
listTasks: '列出任务',
listProjects: '列出项目',
listIdeas: '列出灵感',
deleteProject: '删除项目',
restoreProject: '恢复项目',
purgeProject: '彻底删除项目',
updateProject: '更新项目',
runWorkflow: '运行工作流',
},
// UX-260617-14:审批 loading 超时兜底提示(后端无回执,卡片仍 pending_approval 时复位 + 提示用户)
approveLoadingTimeout: '审批响应超时,请重试或检查后端',
// UX-260617-18:工具失败检测文案(收窄正则后,行首锚定 err_msg 前缀)
// (复用 executionFailed/executionFailedHint,不新增)
// 审批参数键标签
argLabel: {
id: 'ID',