优化: B-21重复渲染治标(Started幂等守卫)+排查文档+CR-43复审回填

This commit is contained in:
2026-06-16 23:33:46 +08:00
parent 22d866d4de
commit 5c2122ac3b
4 changed files with 226 additions and 5 deletions

View File

@@ -0,0 +1,193 @@
# B-260616-21 工具卡片重复渲染排查方案
> 排查性质session-role-diagnose-only仅走查定位确切根因 + 产出两层修复方案,**未改任何代码**。
> 现象:对话记录里 `读取 .../api/ai.ts` 出现两次——`read_file` 两张卡一「0 行 · 7.1KB · running」、一「183 行 · 7.1KB · completed」。
> 关联:[docs/todo.md](../todo.md) B-260616-21前端流转 [useAiEvents.ts](../../src/composables/ai/useAiEvents.ts);后端 emit [audit.rs](../../src-tauri/src/commands/ai/audit.rs)。
---
## 1. 现象与候选根因回顾(已在 todo 登记)
登记在 [docs/todo.md](../todo.md) `### 🔧 2026-06-16 aichat 工具卡片重复渲染排查` 段,两候选:
- **候选 A最贴合现象**:同一 `tool_call_id` 被**重复 emit Started** → 前端 push 两张卡 → `AiToolCallCompleted` 只更首张(`findToolCall` 命中首个)→ 次张卡永驻 `running``parsed?.lines||0` 兜底显 0 行。与「0 行 + 183 行」现象完全吻合。
- **候选 B**agent loop 多轮真读两次(不同 id→ 应两卡皆 183 行 completed与现象不符 → **排除为主因**
本文深化走查定位候选 A 的确切重复 emit 环节,并给出两层修复方案。
---
## 2. 链路走查file:line 独立 grep/Read 核验)
### 2.1 前端流转(已核验)
| 位置 | 行为 | 关键事实 |
|---|---|---|
| `useAiEvents.ts:195-209` | `AiToolCallStarted` 分支:构造 `info``id/name/args/status:'running'`)→ `lastMsg.toolCalls.push(info)` | **直接 push无 id 幂等守卫**。对比同文件 `startToolSlowTimer:58``if (_toolTimers.has(callId)) return` 守卫——Started 漏了同款判重。 |
| `useAiEvents.ts:212-223` | `AiToolCallCompleted` 分支:`findToolCall(event.id)` → 命中则 `tc.status='completed'; tc.result=event.result` | 只 update 一张卡。 |
| `aiShared.ts:41-49` | `findToolCall`:尾部反向扫,**命中首个** `tc.id===id` 返回 | 同 id 被 push 两次时Completed 只更第一张reverse 先撞尾部 = 后 push 的那张?见 §3.1 注)。 |
| `useAiEvents.ts:57-58` | `startToolSlowTimer``if (_toolTimers.has(callId)) return` 幂等守卫 | 后端若重 emit 同 id Started第二张卡仍会 push无守卫但慢执行计时器不重建——**计时器侧有守卫,卡片侧没有**,前端守卫不一致。 |
### 2.2 后端 emit 点(已核验)
| 位置 | emit | 触发条件 |
|---|---|---|
| `audit.rs:541-546` | `AiToolCallStarted { id: draft.id.clone(), ... }` | `process_tool_calls``drafts` 迭代,**每个 draft 一次** |
| `audit.rs:532-533` | `tc_list: Vec<_> = tool_calls_acc.into_iter().collect()` + `sort_unstable_by_key` | `tc_list` 来源是 `tool_calls_acc: HashMap<u32, ToolCallDraft>`**键是流式 index u32非 id 字符串** |
| `audit.rs:573-577` | F-05 高危去重命中 → emit `AiToolCallCompleted``continue` 跳过审批) | **仅 Completed不重 emit Started**——安全 |
| `commands.rs:304-308` | 审批通过 → 执行后 emit `AiToolCallCompleted` | **仅 Completed不重 emit Started**——安全 |
### 2.3 id 来源(已核验)
| 位置 | 行为 |
|---|---|
| `stream_recv.rs:223-229` | LLM 流式 chunk 的 `tool_calls` delta → `tool_calls_acc.entry(tc_delta.index).or_default()``if let Some(id) = &tc_delta.id { draft.id = id.clone(); }` |
| `mod.rs:286-291` | `ToolCallDraft { id: String, name, args }` derive Default`id` 默认空串) |
| `crates/df-ai/src/anthropic_compat.rs:186-191` | Anthropic 流:`id` 直接取 LLM 返回的 `tool_use.id``Option`,可能 None → draft.id 保持空串) |
| `crates/df-ai/src/openai_compat.rs:177-182` | OpenAI 流:`id: tc.id`LLM 返回值,可为 None |
**关键结论**`draft.id` **完全由 LLM stream 提供**`process_tool_calls` / `agentic.rs` **不重新生成也不克隆复用** id。`process_tool_calls``tc_list` 每 draft emit 一次 Started**单次调用内不重复**。
### 2.4 process_tool_calls 调用频次(已核验)
| 位置 | 调用 |
|---|---|
| `agentic.rs:474-477` | `let pending_count = { let mut session = ...; process_tool_calls(&mut session, tool_calls_acc, ...) }` |
- `tool_calls_acc` **by value**move传入用完即消费**每轮 iteration 调一次**,不重入同轮。
- `run_agentic_loop` 主循环(`agentic.rs:176 for iteration in start_iteration..max_iterations`)每轮一次。
---
## 3. 确切根因:候选 A 成立,重复 emit 源在 LLM 同 id 复用
### 3.1 重复 emit 的两可能环节
**所有 emit Started 的代码路径只剩 `audit.rs:541` 一处**grep `AiToolCallStarted` 全仓仅此一处 + mod.rs 枚举定义 + 注释)。要在该处对同一 id emit 两次,必须满足:**`tc_list`= `tool_calls_acc.into_iter()`)含两条 `ToolCallDraft`,其 `id` 字段值相同**。`HashMap<u32, _>` 键是流式 indexu32**两条 draft 可并存**——只要它们 index 不同但 id 字符串相同。
两条可能的产生路径:
#### 路径 A1单次流式内 LLM 在不同 index 上复用同一 tool_use.id最贴合
- LLM尤其 GLM 经 anthropic_compat 端点,已知 id 生成不稳,见 todo `### 🔴 anthropic_compat 多轮工具调用` 段 B-260614-AC1/AC2在**同一次 stream**内,两个 `tool_use` block 复用同一 id 字符串(或一次正常 + 一次重传 delta 残留)。
- `stream_recv.rs:225` `entry(tc_delta.index).or_default()`——按 index 分桶,两条不同 index 的 draft 各自累积,若 LLM 给两条不同 index 的 tool_use 都写了同一 id → `tool_calls_acc` 含两条 id 相同的 draft。
-`process_tool_calls` 对两条各 emit 一次 Started同 id→ 前端 push 两张卡。
#### 路径 A2跨 iteration LLM 重发同一 tool_use.id
- 主 loop`agentic.rs:176`)跨 iteration 时LLM 在不同轮次对相同语义的工具调用复用同一 id 字符串provider 侧缓存/重传)。
- 跨 iteration 的 Started 会落到**同一条 assistant 消息**的 `toolCalls``useAiEvents.ts:202``state.messages[length-1]`,若 `AiAgentRound` 未先新建 assistant 消息则累积同消息;即使新建消息也跨消息污染)。
> **判断**A1单次流式内同 id 不同 index比 A2 更贴合,因 A2 跨轮通常伴 `AiAgentRound` 新建 assistant 消息(`useAiEvents.ts:154-171`),重复卡会分属不同消息气泡,用户报「同一对话记录里出现两次」更可能 A1同一消息内两张。但两者**前端症状一致**(同 id 双卡、Completed 只更其一),**前端守卫可一并治标**。
### 3.2 前端放大缺陷(为何重复 emit 后只剩「0 行 running」
1. `useAiEvents.ts:204-205` `lastMsg.toolCalls.push(info)``findToolCall(event.id)` 守卫 → 后端真 emit 两次同 id Started前端真 push 两张。
2. `aiShared.ts:42` `findToolCall` **反向扫命中首个**——Completed 到达时,反向遍历先撞**后 push 的那张**(尾部)。故 Completed update 的是**第二张**(后 push**第一张**(先 push永驻 `running`,显示 `0 行 · 7.1KB``ToolCard.vue:72` `parsed?.lines||0` running 态无 result 兜底 0
- **更正 §2.1 表「Completed 只更第一张」表述**:实测 findToolCall 反向命中尾部,故被更的是后 push 的那张,残留 running 的是先 push 的。现象「一 0 行 running、一 183 行 completed」与「先 push 残留 running / 后 push 被 Completed 更」一致。
3. 即使后端不重复 emit**理论上 findToolCall 反向命中 + Started 无守卫**的组合本身就在「同 id 两次 Started」时产出「一卡永久 running」坏形态——这是前端层独立缺陷。
### 3.3 与 F-260616-05 的区别(已在 todo 标注)
- F-05batch53**不同 tool_call_id** 的同 tool_name+args 重复调用去重High risk 进审批门前 `find_cached_high_risk_result` 反向扫 messages。治的是「LLM 真调两次同命令」。
- B-260616-21**同一 tool_call_id** 被重复 emit Started。治的是「LLM 给两个 tool_use block 复用同一 id 字符串」。
- 两者维度不同F-05 的去重逻辑(按 args 匹配、跳过审批)**不能拦截**本条(本条 id 相同、index 不同F-05 走的是 draft.id 维度的审批插桩,不防同 id 多 emit
---
## 4. 修复方案(两层)
### 方案 ① 前端幂等守卫(治标·确定性低风险·推荐立即落地)
**改动**`src/composables/ai/useAiEvents.ts:195-209` `AiToolCallStarted` 分支push 前加 `findToolCall(event.id)` 守卫:
```ts
case 'AiToolCallStarted': {
// B-260616-21: id 幂等守卫——后端若对同一 tool_call_id 重复 emit Started
// LLM 在不同 index 复用同 id / 跨轮同 id仅保留首张卡避免「同 id 双卡、
// Completed 只更其一、另一张永驻 running 显 0 行」。对齐 startToolSlowTimer:58 守卫风格。
if (findToolCall(event.id)) {
// 已存在同 id 卡:补挂慢执行计时器(防首张 timer 被中途清后此 emit 不重建),
// 但不重复 push 卡片。startToolSlowTimer 自身有 _toolTimers.has 守卫,重复调安全。
startToolSlowTimer(event.id, event.name)
break
}
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'running',
}
const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.toolCalls = lastMsg.toolCalls || []
lastMsg.toolCalls.push(info)
}
startToolSlowTimer(event.id, event.name)
break
}
```
**生效语义**
- 后端重复 emit 同 id Started → 第二次起命中 `findToolCall` → 不 push 新卡 → Completed无论命中首张或尾部因只有一张卡正确 update → 无残留 running 卡。
- 同 id 跨 assistant 消息A2 场景,不同消息的 toolCalls也守得住——`findToolCall` 扫**全部消息**`aiShared.ts:42` 从尾反向遍历 `state.messages`),跨消息同 id 也会命中。
- 风险:若 LLM 真用同 id 调两次**不同语义**的工具id 冲突但语义不同,极罕见),第二次工具的卡片会被吞 → 用户看不到第二次调用。但**这种 id 冲突本身就是 LLM 协议违规**(同 id 必同语义tool_result 按 id 配对),后端 `ChatMessage::tool_result(&draft.id, ...)` 也只按 id 配对一个结果——故前端吞掉第二张是正确行为,与后端语义一致。
**改动范围**:单文件单 case~6 行净增,`findToolCall` 已 import`useAiEvents.ts:20`)。对齐同文件 `startToolSlowTimer:58` 守卫模式,无新机制。`vue-tsc` 应 0 err纯前端逻辑无类型变动
**与 startToolSlowTimer 守卫的一致性**:本修复使 Started 的卡片侧与计时器侧都具备 id 幂等守卫消除「计时器有守卫、卡片没有」的前端守卫不一致§2.1)。
### 方案 ② 后端治本(定位重复 emit 源·需进一步取证)
前端守卫是兜底,根因在后端真发了同 id 两次 Started。治本需先**确认是 A1 还是 A2**
**取证步骤**(不改代码,加临时日志或读现有日志):
1.`audit.rs:541` emit Started 前加 `tracing::info!`(临时):打印 `draft.id` + `draft.name` + `tc_list.len()` + 该 id 在 tc_list 中出现次数。复现后看是否单次 `process_tool_calls` 调用内同 id 出现 ≥2 次A1还是跨调用出现A2
2.`stream_recv.rs:226` `draft.id = id.clone()` 处加日志:打印 `tc_delta.index` + `id`,看 LLM 流式是否给不同 index 同 id。
**取证后治本方向A1 vs A2 分支)**
- **若 A1单次流式内同 id 不同 index**:根因在 LLM provider 层anthropic_compat / openai_compatid 生成不稳。治本点在 `stream_recv.rs:225` 或 provider 解析层——
- 选项 a`process_tool_calls` 内对 `tc_list``draft.id` 去重(同 id 保留首个 index丢弃后续
- 选项 bprovider 层 id 缺失/冲突时生成占位 id对齐已落地的 B-260614-AC1/AC2 占位 id 机制 `tool_missing_{idx}` / `tool_use_{idx}`)。
- 倾向 bprovider 层根治process 层去重是兜底)。
- **若 A2跨 iteration 同 id**:根因在 LLM 跨轮复用 id。治本点在 `process_tool_calls` 入口校验 `draft.id` 是否已在 `session.messages` 历史 tool_result 中存在(跨轮去重)——但这与 F-260616-05 去重机制部分重叠需谨慎区分F-05 按 args 去重 High risk 跳审批;本条按 id 去重跨轮 Low/Med/High 一致)。倾向:跨轮同 id 视为 LLM 协议违规,前端守卫兜底即可,后端不额外处理(避免与 F-05 去重逻辑耦合)。
**治本建议**:先落地方案 ① 前端守卫(立即消除用户可感坏形态),取证步骤并行进行,据 A1/A2 结论再决定后端治本是否必要(若 A1 频发provider 层补占位 id 生成;若 A2 罕见,前端守卫足矣)。
---
## 5. 风险评估
| 项 | 方案 ① 前端守卫 | 方案 ② 后端治本 |
|---|---|---|
| 行为变更 | 同 id 第二次 Started 不再 push 卡(吞掉重复) | 视 A1/A2 分支而定 |
| 兼容性 | LLM 协议合规(同 id 必同语义)下零影响;协议违规下吞掉违规第二张,与后端 tool_result 按 id 配对语义一致 | 需配套测试,避免误伤合法重发 |
| 回归面 | 单 case无类型/数据结构变动 | provider 层 / process 层,触及多文件 |
| 建议优先级 | **P2 立即落地**(治标兜底,消除用户可感坏形态) | **取证后再定**(可能不必要,若 A1 罕见) |
---
## 6. 待办(回写 docs/todo.md
- [ ] B-260616-21 方案 ① 前端 `useAiEvents.ts:195-209` Started 分支加 `findToolCall(event.id)` 幂等守卫(~6 行,对齐 `startToolSlowTimer:58` 守卫,确定性低风险,立即落地治标)。
- [ ] B-260616-21 方案 ② 取证:临时日志确认 A1单次流式内同 id 不同 index/ A2跨 iteration 同 id据结论决定后端治本provider 占位 id 生成 / process 层去重 / 不处理)。**依赖** ① 落地后再做(① 兜底后现象消失,取证需临时去 ① 守卫复现)。
---
## 7. 核验证据汇总(防上下文污染·独立 grep/Read
| 结论 | 证据 |
|---|---|
| AiToolCallStarted 全仓唯一 emit 点 | `grep AiToolCallStarted``audit.rs:541`emit+ `mod.rs:95`(枚举)+ `mod.rs:19`(注释) |
| F-05 高危去重不重 emit Started | `audit.rs:573-577` 仅 emit `AiToolCallCompleted` + `continue` |
| 审批执行不重 emit Started | `commands.rs:304-308` 仅 emit `AiToolCallCompleted` + `AiApprovalResult` |
| draft.id 完全来自 LLM stream | `stream_recv.rs:226` `draft.id = id.clone()`id 取自 `tc_delta.id`LLM 提供) |
| process_tool_calls 不重生成/克隆 id | `audit.rs:538-549``draft.id.clone()` 透传给 emit无新 id 生成 |
| tc_list 键是 index 非 id | `audit.rs:532` `tool_calls_acc.into_iter()``tool_calls_acc: HashMap<u32, ToolCallDraft>`u32 = 流式 index |
| 前端 Started 无守卫 | `useAiEvents.ts:202-206` 直接 push`findToolCall` 判重 |
| 前端 findToolCall 命中首个(反向) | `aiShared.ts:42-49``state.messages.length-1` 反向遍历,命中即 return |
| startToolSlowTimer 有守卫(对比) | `useAiEvents.ts:58` `if (_toolTimers.has(callId)) return` |
| ToolCard running 兜底 0 行 | `ToolCard.vue:72` `parsed?.lines || 0`running 态无 result |
| ToolCallDraft derive Default | `mod.rs:286` `#[derive(Debug, Clone, Default)]`id 默认空串 |

View File

@@ -99,6 +99,16 @@
- [ ] UX-260616-02 [P3] — **「全部收起」与搜索/技能区合并一行**。当前 ToolCardList.vue 有两个独立行:①batch-approve 栏(line 4-11,pendingCount>0 时显示)②global-toggle 栏(line 14-17,collapsibleGroupCount>0 时显示「▾ 全部收起」)。用户要求将「全部收起」与附近的操作元素(search files 搜索文件/技能触发等)放到同一行,减少垂直空间占用。**需确认**:「search files」具体指哪个 UI 元素(i18n `ai.searchFiles` 渲染位置需定位,可能在输入框上方 skill 栏或工具卡区域)。**改动方向**:global-toggle 从独占行改为 inline 元素,与相邻操作栏 flex 同行。—— ToolCardList.vue(:13-17 template + :278-295 CSS .ai-tool-global-toggle) - [ ] UX-260616-02 [P3] — **「全部收起」与搜索/技能区合并一行**。当前 ToolCardList.vue 有两个独立行:①batch-approve 栏(line 4-11,pendingCount>0 时显示)②global-toggle 栏(line 14-17,collapsibleGroupCount>0 时显示「▾ 全部收起」)。用户要求将「全部收起」与附近的操作元素(search files 搜索文件/技能触发等)放到同一行,减少垂直空间占用。**需确认**:「search files」具体指哪个 UI 元素(i18n `ai.searchFiles` 渲染位置需定位,可能在输入框上方 skill 栏或工具卡区域)。**改动方向**:global-toggle 从独占行改为 inline 元素,与相邻操作栏 flex 同行。—— ToolCardList.vue(:13-17 template + :278-295 CSS .ai-tool-global-toggle)
- [ ] UX-260616-03 [P2] — **对话内容输出时自动收起旧工具卡片分组**。需求:当 AI 输出新内容(流式 delta / 新工具调用 / 新轮次)滚动到下方时,上方已完成的旧消息中的工具卡片分组自动收起,保持视野聚焦当前内容。**现状**:机制已存在——`ToolCardList.collapseInactive(activeIds)`(:191-199)供父组件调用,`AiChat.vue:1657-1661` 已有 watch 调用(refs 数组逐实例 collapseInactive)。**增强方向**:a)触发时机扩展——当前可能仅在特定时机调用,可扩展到:`AiTextDelta` 新消息开始时 + `AiAgentRound` 新轮次时 + 用户滚动接近底部时(跟随阅读位置自动收起已读内容)b)平滑过渡——收起加 CSS transition(高度动画 200ms)避免内容突然消失跳变c)可选:记忆用户手动展开的分组不自动收起(expandedCards Set 区分用户主动展开 vs 默认态)。—— ToolCardList.vue(collapseInactive + CSS transition) + AiChat.vue(watch 触发时机扩展) - [ ] UX-260616-03 [P2] — **对话内容输出时自动收起旧工具卡片分组**。需求:当 AI 输出新内容(流式 delta / 新工具调用 / 新轮次)滚动到下方时,上方已完成的旧消息中的工具卡片分组自动收起,保持视野聚焦当前内容。**现状**:机制已存在——`ToolCardList.collapseInactive(activeIds)`(:191-199)供父组件调用,`AiChat.vue:1657-1661` 已有 watch 调用(refs 数组逐实例 collapseInactive)。**增强方向**:a)触发时机扩展——当前可能仅在特定时机调用,可扩展到:`AiTextDelta` 新消息开始时 + `AiAgentRound` 新轮次时 + 用户滚动接近底部时(跟随阅读位置自动收起已读内容)b)平滑过渡——收起加 CSS transition(高度动画 200ms)避免内容突然消失跳变c)可选:记忆用户手动展开的分组不自动收起(expandedCards Set 区分用户主动展开 vs 默认态)。—— ToolCardList.vue(collapseInactive + CSS transition) + AiChat.vue(watch 触发时机扩展)
- [ ] UX-260616-04 [P1] — **审批通过后工具卡片仅显示裸 JSON**。用户实测:审批前 `pending_approval` 态渲染友好(参数键值对+风险提示+审批按钮),审批通过后 `completed` 态 body 仅显示一段 JSON 数据。**根因**:`ToolCard.vue` 模板渲染链对 `read_file`/`list_directory`/`write_file` 有专门 UI 分支,其余工具全部走通用兜底(L110 `v-else-if tc.result && tc.status === 'completed'`)→ `formatToolResult(tc)` → 兜底 `return formatJson(r)` 裸 JSON 美化输出。**代码核对**:①`formatToolResult`(L270-289)只特化 `write_file`,其余全 `formatJson``toolResultSummary`(L476-498,header 摘要)覆盖 `list_tasks`/`list_projects`/`list_ideas`/`create_project`/`create_task`/`create_idea`/`update_project`/`delete_project`/`restore_project`/`purge_project`/`run_workflow` 共 11 工具,但 body 的 `formatToolResult` 不共享此逻辑 ③两函数覆盖范围严重不一致(header 有摘要但 body 展开仍裸 JSON)。**后端返回值核对**(tool_registry.rs):`update_task``{id,field,updated}` / `delete_task``{deleted,id}` / `delete_file``{path,deleted,...}` / `run_command``{command,exit_code,stdout,stderr,...}`(可能很大) / `patch_file``{path,changed,size_diff,...}` / `append_file``{path,bytes_written,new_size}` / `rename_file``{from,to,renamed,...}` / `search_files``{path,pattern,results,total,...}` / `file_info``{path,exists,size,lines,...}` / `bind_directory``{id,path,stack,bound}` / `advance_task``{...}` ——全部走裸 JSON。**修复方向**:统一 `formatToolResult` 的 switch 覆盖全部工具产出人类可读摘要(复用 `toolResultSummary` 已有 i18n key + 补缺失 key 如 `commandOk`/`commandFailed`/`patchedFile`/`appendedTo`/`renamed`/`foundN` 等),同时补 `toolResultSummary` 缺失 case(`update_task`/`delete_task`/`delete_file`/`run_command`/`patch_file`/`append_file`/`rename_file`/`search_files`/`file_info`/`bind_directory`/`advance_task`)。—— `src/components/ToolCard.vue`(`formatToolResult` L270 + `toolResultSummary` L476) + `src/i18n/{zh-CN,en}/aiTool.ts`(补 key) - [ ] UX-260616-04 [P1] — **审批通过后工具卡片仅显示裸 JSON**。用户实测:审批前 `pending_approval` 态渲染友好(参数键值对+风险提示+审批按钮),审批通过后 `completed` 态 body 仅显示一段 JSON 数据。**根因**:`ToolCard.vue` 模板渲染链对 `read_file`/`list_directory`/`write_file` 有专门 UI 分支,其余工具全部走通用兜底(L110 `v-else-if tc.result && tc.status === 'completed'`)→ `formatToolResult(tc)` → 兜底 `return formatJson(r)` 裸 JSON 美化输出。**代码核对**:①`formatToolResult`(L270-289)只特化 `write_file`,其余全 `formatJson``toolResultSummary`(L476-498,header 摘要)覆盖 `list_tasks`/`list_projects`/`list_ideas`/`create_project`/`create_task`/`create_idea`/`update_project`/`delete_project`/`restore_project`/`purge_project`/`run_workflow` 共 11 工具,但 body 的 `formatToolResult` 不共享此逻辑 ③两函数覆盖范围严重不一致(header 有摘要但 body 展开仍裸 JSON)。**后端返回值核对**(tool_registry.rs):`update_task``{id,field,updated}` / `delete_task``{deleted,id}` / `delete_file``{path,deleted,...}` / `run_command``{command,exit_code,stdout,stderr,...}`(可能很大) / `patch_file``{path,changed,size_diff,...}` / `append_file``{path,bytes_written,new_size}` / `rename_file``{from,to,renamed,...}` / `search_files``{path,pattern,results,total,...}` / `file_info``{path,exists,size,lines,...}` / `bind_directory``{id,path,stack,bound}` / `advance_task``{...}` ——全部走裸 JSON。**修复方向**:统一 `formatToolResult` 的 switch 覆盖全部工具产出人类可读摘要(复用 `toolResultSummary` 已有 i18n key + 补缺失 key 如 `commandOk`/`commandFailed`/`patchedFile`/`appendedTo`/`renamed`/`foundN` 等),同时补 `toolResultSummary` 缺失 case(`update_task`/`delete_task`/`delete_file`/`run_command`/`patch_file`/`append_file`/`rename_file`/`search_files`/`file_info`/`bind_directory`/`advance_task`)。—— `src/components/ToolCard.vue`(`formatToolResult` L270 + `toolResultSummary` L476) + `src/i18n/{zh-CN,en}/aiTool.ts`(补 key)
- [ ] UX-260616-05 [P1] — **打断按钮只停当前回复 + 队列保留续发(不清队列)**。用户需求:发送队列有消息时,打断按钮应只打断当前回复,并把队列中的多条消息一并发送给后续对话。现状:`stopChat`(useAiSend.ts:365-370) L368 `state.queue = []` **清空队列** → 用户生成中排队的消息随打断被**静默丢弃**。技术底座已就绪(已核后端):`stopChat``aiApi.stopChat`→后端 `stop_flag.load`(agentic.rs:178)→收尾 `save_conversation`(:185 已生成文本入库)+emit **`AiCompleted`**(agentic.rs:190注释明确「保证前端收事件时后端已可接下一条发送队列续发不被拒」)→前端 `ai-drain-queue``drainQueue`(useAiSend.ts:228) shift 队首续发。**最小修复(确定)**:删 stopChat L368 `state.queue=[]`,打断后 AiCompleted 自然触发 drainQueue 续发队首;当前未完成回复已入库(:185)保留为截断回复,无需额外处理。**决策点(待定)——「队列多条一并发送」语义****(a) 逐条续发**(删清队列即可,零改 drainQueueAiCompleted 链式触发每条保各条上下文独立vs **(b) 合并成一条消息**(队列多条 text 拼接送,需改 drainQueue 为 bulkDrain 或合并 text 调一次 sendMessage。**倾向 (a)**(逐条保上下文独立+零改链路;"一并"理解为「都送出不丢弃」非字面合并),**待用户确认**。— src/composables/ai/useAiSend.ts(stopChat:365 + drainQueue:228) + 后端 agentic.rs(:178/:190 stop 收尾 emit AiCompleted 已就绪)。
- [ ] UX-260616-06 [P2] — **队列消息支持编辑**。用户需求:队列中的消息支持编辑。现状:队列 UI(AiChat.vue:511-516) 每项只有 × 删除(cancelQueued:353),无编辑入口;队列项结构 `{ text, skill?, enqueuedAt }`(stores/ai.ts:70)。**改动(纯前端低风险)**:队列项加「编辑」按钮 → 点击切 inline input单行 textarea→ 回车/失焦写回 `state.queue[idx].text`、ESC 取消;新增 `editQueued(index, newText)`(useAiSend.ts) splice 写回 + store 导出 + composable return。**决策点(待定)**skill 是否可编辑(**倾向只编 text**——skill 来自发送时技能联想,编辑态不暴露 skill 改简化交互。i18n 补 edit/save/cancel key。— src/components/AiChat.vue(:511-516 队列项 UI + .ai-queue-item CSS:2856) + src/composables/ai/useAiSend.ts(editQueued 新增) + stores/ai.ts + i18n。
- [ ] UX-260616-07 [P2/依赖 UX-05] — **队列消息支持立即发送**。用户需求:队列中的消息支持立即发送。现状:队列项只能 × 删除或等 drainQueue 串行轮到,无插队。**决策点(待定)——「立即发送」语义****(a) 插队=打断当前+立即发这条**splice 出该项→stopChat→sendMessage 该条;复用 UX-05 stop→AiCompleted→drain 链路,当前未完成回复走 stop 截断保留vs **(b) 移到队首等当前完成优先**splice 出→unshift 队首drainQueue 下一轮先发它,不打断当前)。**倾向 (a)**"立即"字面=马上发,且复用 UX-05 链路;但须注意 splice 后调 stopChat 已不清队列(UX-05),避免已发送项重复入队)。**待 UX-05 打断语义定后同批设计**(共享 stop+drain 链路)。— src/components/AiChat.vue(:511-516 队列项加「立即发送」按钮) + src/composables/ai/useAiSend.ts(sendQueuedNow 新增)。
- [ ] F-260616-15 [P1] — **AI Chat 上下文管理增强:会话分段(清理上下文不删记录)+ 手动上下文压缩**。两个子需求:
**① 会话分段(清理上下文,保留记录)**:用户点「清理上下文」后,不清删 DB 消息,而是给当前消息打标记(`status="archived_segment"`),后续新消息作为新段继续同一对话。同一对话内产生 a'/a''/a''' 多段DB 全量保留可回溯LLM 上下文只看当前段。**现状**`ai_chat_clear`(commands.rs:360) 直接 `clear_messages``messages='[]'`(全删)。`ChatMessage.status`(df-ai-core/provider.rs:59) 已有 `None/"active"/"truncated"` 三态,`is_active()` 过滤 truncated。`ContextManager.build_for_request`(context.rs:219) 发送前已调 `sanitize_messages` 过滤 `!is_active()`。**改动方向**a) 新增 status 值 `"archived_segment"`(语义=被清理上下文标记,不进 LLM 上下文但前端可见可展开b) `is_active()` 扩展过滤 archived_segment不进 build_for_requestc) 新 IPC `ai_chat_clear_context`(区别于 `ai_chat_clear` 全删):给当前所有 active 消息标记 `status="archived_segment"` + 插入一条 system 消息「--- 上下文已清理 ---」做分隔线 + ContextManager 清内存只保留分隔线后的消息d) 前端:清空按钮拆为「清空对话」(全删,现有)+「清理上下文」分段标记e) 前端消息列表渲染 archived_segment 段为折叠的灰色分隔区点击展开看历史段active 段正常展示。**关键约束**:工具调用三元组不能跨段拆散(整组标记)。
**② 手动上下文压缩LLM 摘要)**:用户点「压缩上下文」后,把当前段内所有 active 消息发给 LLM 生成摘要,摘要替换原始消息作为新的上下文起点。**现状**:无压缩能力,`ContextManager` 超预算时直接丢弃旧消息(无摘要保留语义)。`LlmProvider::complete()`(df-ai-core/provider.rs:210) 可用于非流式摘要调用。**改动方向**a) 新 IPC `ai_chat_compress_context`:取当前 active 消息 → 构建 summary prompt「请将以下对话总结为关键上下文摘要保留用户意图/关键决策/已修改文件/重要约束」)→ `provider.complete()` 调用 → 摘要作为 system 消息替换原始消息(原始消息标记 `status="compressed"` 保留 DBb) ContextManager 清内存只保留摘要 system + 后续新消息c) 前端:压缩按钮 + loading 态 + 压缩后显示摘要卡片可展开看被压缩的原始消息d) 摘要 prompt 模板放 prompt.rs 可配。**关键约束**:压缩是单向的(不能"解压缩"恢复 LLM 上下文,但 DB 原始消息保留可查看);压缩后 token 计数重置。
**涉及文件**`df-ai-core/src/provider.rs`(ChatMessage.status 扩展) + `df-ai/src/context.rs`(build_for_request 过滤) + `src-tauri/src/commands/ai/commands.rs`(2 新 IPC) + `src-tauri/src/commands/ai/prompt.rs`(压缩 prompt 模板) + `src/api/ai.ts` + `src/composables/ai/useAiPanel.ts` + `src/components/AiChat.vue`(按钮 UI + 分段渲染) + `src/i18n/{zh-CN,en}/aiChat.ts`
- [x] ✅(batch60·2026-06-16·workflow whae812z5+主代核查,cargo 0err) F-260616-13 [P2] — **build_for_request 持锁重活 + system_prompt token 每轮重估**。性能分析批次发现:每轮 `stream_llm``session_arc.lock()``agentic.rs:251`)持锁期间做 `TokenEstimator::estimate_text(system_prompt)`L252+ `build_for_request`L253 history clone + 裁剪。system_prompt loop 外固定传入,**每轮重估其 token 是浪费**可缓存build_for_request 持锁做 history clone 是重活消息多时200 cap锁持有期长。**方向**①system_prompt token loop 外算一次缓存 ②build_for_request 先 clone messages 释放锁再裁剪。低收益优化。— agentic.rs:251-253。 - [x] ✅(batch60·2026-06-16·workflow whae812z5+主代核查,cargo 0err) F-260616-13 [P2] — **build_for_request 持锁重活 + system_prompt token 每轮重估**。性能分析批次发现:每轮 `stream_llm``session_arc.lock()``agentic.rs:251`)持锁期间做 `TokenEstimator::estimate_text(system_prompt)`L252+ `build_for_request`L253 history clone + 裁剪。system_prompt loop 外固定传入,**每轮重估其 token 是浪费**可缓存build_for_request 持锁做 history clone 是重活消息多时200 cap锁持有期长。**方向**①system_prompt token loop 外算一次缓存 ②build_for_request 先 clone messages 释放锁再裁剪。低收益优化。— agentic.rs:251-253。

View File

@@ -148,7 +148,20 @@
--- ---
### CR-260616-43 AiNode 自审阶段2(AiNode持db+ai_self_review节点+DAG透传+前端展示) — 🟡 待审 ### CR-260616-43 AiNode 自审阶段2(AiNode持db+ai_self_review节点+DAG透传+前端展示) — ✅ 已审(PASS)
- **复审结论(2026-06-16·CR-43 agent 亲跑 cargo+vue-tsc+test,142s)**: ✅ **PASS** — 🔴0 🟡0 ⚪1
- **验证**: `cargo check --workspace` EXIT 0(5 warning 全 pre-existing dead_code)/ `cargo test -p df-nodes` **79 passed; 0 failed; 1 ignored** / `npx vue-tsc --noEmit` EXIT 0。
- **①AiSelfReviewNode prompt 四维度+JSON 兜底完备 PASS**:build_review_prompt(ai_node.rs:405-424) 含需求+产出+四维度+输出格式 + parse_review_json(:360-381) 覆盖非 JSON/非 Object/缺 verdict → verdict=unknown 兜底 + 代码块剥离(:363-367 ```json 围栏) + 单测(:928-963) 合规/非法/fence 场景全过。
- **②写回 output_json review 子字段 PASS**:读现有合并不覆盖 text(:514-519 先读 output_obj 若 JSON 则复用) + review 子字段写入(:520-534 含 verdict+reviewed_at+reviewer_model) + 落库失败只 warn 不阻断(:538-544)。
- **③DAG 透传 PASS**:NodeOutput.data 平铺(:550-556 verdict/summary/suggestions) + human_review 经 inputs 读(task_workflow_templates.rs:72 edge ai_self_review→human_review) + HumanNode 零改动(git diff 核验 human_node.rs 在 3 commit 中无变动)。
- **④state.rs 注册+工厂闭包 PASS**:AiNode 注册(:261-263 AiNode::new(ai_db)) + AiSelfReviewNode 注册(:269-270 AiSelfReviewNode::new(review_db)) + move db.clone() 闭包捕获正确。
- **⑤testing 模板节点类型对齐 PASS**:模板 L61 注册 "ai_self_review" 节点 + L72 edge 透传 + 单测 L123-143 验证节点类型+边方向+options。
- **⑥前端 schema 契约一致 PASS**:types.ts:102 output_json? 字段 + L98-100 schema 注释 + TaskDetail.vue:229-234 parsedOutput computed try/catch 兜底 + reviewVerdictClass/reviewLabel/reviewSuggestions 均从 parsedOutput.review 读 + 字段名对齐 verdict/summary/suggestions。
- **⑦i18n 双语 PASS**:zh-CN/taskDetail.ts:23-27 review.pass/fail/unknown + en/taskDetail.ts:23-27 对称翻译 + 特殊字符无转义需求(值均为静态枚举字面量)。
- **⑧P0 安全债登记确认**:api_key 明文 config 注入违背 FR-S1 已在 memory/devflow-aichat-review-pending.md 登记 + 本次 CR 范围已实现 resolve_provider 三路径(ai_node.rs:62-135 完整实现) + F-260616-07c 阻塞⑥联调已记录。
- **⚪ CR-43-1(low,单测降级声明)**:AiSelfReviewNode.execute 完整 mock LlmProvider 单测本次降级(仅 parse_review_json+update_field 单测覆盖,P3 待补)。ai_node.rs:589-875 单测已覆盖 resolve_provider 双路径+parse_review_json 兜底+update_field 路径,end-to-end execute 单测依赖真实 LLM(#[ignore] glm_live_complete 存在),非阻断。
- **待修项回流 todo**: **无**(1 low 单测降级非阻断,已在 CR 信息说明 P3 待补)。
- **范围**: F-260616-07 阶段3 AiNode 自审闭环②③④⑤(3 commit:539b5ed 迁移 + c10adaf ai 侧 + 741b0b9 前端)。①**①迁移**(539b5ed):TaskRecord 加 `output_json: Option<String>` + V17 迁移(幂等补列) + crud 白名单加 output_json(L346)+SELECT/INSERT/UPDATE 全链路 + 5 构造点补 None。②**ai_node.rs②③④**(c10adaf):AiNode 持 db(`Arc<Database>`,对齐 TaskAdvanceNode:98-110 先例)+ execute 后 `config["task_id"]` 存在则 `update_field` 落 output_json(产出 schema {text,model,usage},落库失败只 warn 不阻断)+ AiSelfReviewNode 独立节点(parse_review_json 兜底 verdict=unknown + build_review_prompt 四维度 + 写回 review 子字段不覆盖 text)+ state.rs L269-270 注册 ai_self_review + ai 工厂 L262 `AiNode::new(db)` + testing 模板 L61 ai_self_review + L72 edge(ai_self_review→human_review)+ NodeOutput.data 平铺透传 + HumanNode 零改动。③**前端⑤**(741b0b9):types.ts output_json?(schema 注释)+ TaskDetail.vue parsedOutput computed(try/catch 兜底)+review-card 红绿标+renderedOutput 复用 useRendered + i18n zh/en taskDetail.output/review 双语。 - **范围**: F-260616-07 阶段3 AiNode 自审闭环②③④⑤(3 commit:539b5ed 迁移 + c10adaf ai 侧 + 741b0b9 前端)。①**①迁移**(539b5ed):TaskRecord 加 `output_json: Option<String>` + V17 迁移(幂等补列) + crud 白名单加 output_json(L346)+SELECT/INSERT/UPDATE 全链路 + 5 构造点补 None。②**ai_node.rs②③④**(c10adaf):AiNode 持 db(`Arc<Database>`,对齐 TaskAdvanceNode:98-110 先例)+ execute 后 `config["task_id"]` 存在则 `update_field` 落 output_json(产出 schema {text,model,usage},落库失败只 warn 不阻断)+ AiSelfReviewNode 独立节点(parse_review_json 兜底 verdict=unknown + build_review_prompt 四维度 + 写回 review 子字段不覆盖 text)+ state.rs L269-270 注册 ai_self_review + ai 工厂 L262 `AiNode::new(db)` + testing 模板 L61 ai_self_review + L72 edge(ai_self_review→human_review)+ NodeOutput.data 平铺透传 + HumanNode 零改动。③**前端⑤**(741b0b9):types.ts output_json?(schema 注释)+ TaskDetail.vue parsedOutput computed(try/catch 兜底)+review-card 红绿标+renderedOutput 复用 useRendered + i18n zh/en taskDetail.output/review 双语。
- **维度**: ①AiSelfReviewNode prompt 四维度+JSON 兜底完备性(parse_review_json 各路径 verdict 覆盖,代码块 fence 剥离重试) ②写回 output_json review 子字段(读现有合并不覆盖 text,reviewed_at/reviewer_model) ③DAG 透传(NodeOutput.data 平铺,human_review 经 inputs 读,HumanNode 零改动核验) ④state.rs 注册+工厂闭包 move db.clone() ⑤testing 模板节点类型对齐 ⑥前端 schema 契约一致(types.ts 与 ai_node.rs 写回 schema 字段名对齐 verdict/summary/suggestions) ⑦i18n 特殊字符转义 ⑧**P0 安全债登记**(api_key 明文 config 注入违背 FR-S1,跨 crate 阻塞,F-260616-07c 待决) ⑨主代亲跑 cargo df-nodes 73 passed + workspace EXIT 0 + vue-tsc EXIT 0。 - **维度**: ①AiSelfReviewNode prompt 四维度+JSON 兜底完备性(parse_review_json 各路径 verdict 覆盖,代码块 fence 剥离重试) ②写回 output_json review 子字段(读现有合并不覆盖 text,reviewed_at/reviewer_model) ③DAG 透传(NodeOutput.data 平铺,human_review 经 inputs 读,HumanNode 零改动核验) ④state.rs 注册+工厂闭包 move db.clone() ⑤testing 模板节点类型对齐 ⑥前端 schema 契约一致(types.ts 与 ai_node.rs 写回 schema 字段名对齐 verdict/summary/suggestions) ⑦i18n 特殊字符转义 ⑧**P0 安全债登记**(api_key 明文 config 注入违背 FR-S1,跨 crate 阻塞,F-260616-07c 待决) ⑨主代亲跑 cargo df-nodes 73 passed + workspace EXIT 0 + vue-tsc EXIT 0。

View File

@@ -199,10 +199,15 @@ export function handleEvent(event: AiChatEvent) {
args: event.args, args: event.args,
status: 'running', status: 'running',
} }
const lastMsg = state.messages[state.messages.length - 1] // B-260616-21: 同 tool_call_id 重复 emit Started 时(GLM anthropic_compat id 不稳),
if (lastMsg && lastMsg.role === 'assistant') { // 仅挂计时器不重复 push(对齐 startToolSlowTimer 守卫风格),防残留 running 空卡(0 行·N KB)。
lastMsg.toolCalls = lastMsg.toolCalls || [] // 详 docs/02-架构设计/B-260616-21排查方案-2026-06-16.md 方案①治标(后端治本待取证)。
lastMsg.toolCalls.push(info) if (!findToolCall(event.id)) {
const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.toolCalls = lastMsg.toolCalls || []
lastMsg.toolCalls.push(info)
}
} }
// B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态) // B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
startToolSlowTimer(event.id, event.name) startToolSlowTimer(event.id, event.name)