修复: sendQueuedNow立即发送退化入队+裸diff围栏渲染+工具卡轮次自动收起

This commit is contained in:
2026-06-17 13:00:37 +08:00
parent 9d1e5c2737
commit ca9b3187b5
4 changed files with 727 additions and 7 deletions

View File

@@ -0,0 +1,285 @@
# AI 聊天组件极端数据场景多角度分析报告 — 2026-06-17
> 分析范围:`AiChat.vue`3917行/166KB、`useMarkdown.ts`192行、`useAiVirtualScroll.ts`162行、`ToolCard.vue`1177行
> 方法:六维边界值矩阵 × 代码走查 × 性能估算
> 来源AI Chat 上下文会话中逐文件走读后交付
---
## 一、超长文本场景
### P0-A单条 AI 消息完成态 >100K 字符 — 无高度约束DOM 爆炸
**代码路径**
```
renderContent(msg) → renderMd(msg.content) → _marked.parse(text) → _purify.sanitize → v-html
```
**定位**`AiChat.vue:468` `<div v-else v-html="renderContent(item.msg)"></div>` + `useMarkdown.ts:182-190 renderMd()`
**风险论证**
- `.ai-msg-bubble--ai`L3260-3278仅有 `max-width: var(--df-msg-max-width)`90%)和 `overflow-wrap: anywhere`**无 `max-height` 限制**。
- 100K 字符 markdown → marked.parse 可能耗时 >200ms 阻塞主线程marked 是同步解析)。
- DOMPurify 对 100K+ HTML 字符串的 sanitize 同样 O(n) 开销。
- `_mdCache` 以**完整原始文本为 key**100K 文本作为 Map key 本身就是内存开销。
- 缓存上限 200 条但无 LRU`size>200` 时**全量 clear**L189频繁切换消息可能导致缓存抖动。
**复现条件**AI 返回超长代码审查报告 / 全文翻译 / 大段文档。
**修复方向**`.ai-msg-bubble--ai``max-height:600px` + `overflow-y:auto` + "内容过长,展开查看"折叠机制。
---
### P0-B流式生成超长消息 — 每帧全文 lexerO(n) 随长度线性增长
**代码路径**
```
scheduleStreamParse(text) → rAF → renderStreamingBlocks(lastStreamText) → splitBlocks(text) → marked.lexer(text) 全文解析
```
**定位**`AiChat.vue:821-851 splitBlocks()` 每帧对全文调 `marked.lexer(text)`
**风险论证**
- `splitBlocks` 每 rAF 调用 `getMarked()!.lexer(text)`,解析**整个流式文本**而非增量。
- 流式到 50K 字符、200+ token blocks 时,每帧 lexer 开销显著marked lexer 是 O(n) 的)。
- 更致命的子场景:**无换行的超长连续文本**(如 LLM 返回无段落分隔的巨段)→ `splitBlocks` 输出为单一巨大 block → 该 block 作为"尾块"每帧调 `parseBlockNoCache`marked.parse + DOMPurify→ 每帧全量重渲染。
- `_blockCache` 虽缓存已完成块,但**尾块始终重算**,流式越长越痛。
**性能估算**100K 文本 × 60fps rAF → 每帧 `marked.lexer(100K)` → 假设 1μs/char → ~100ms/frame → **严重掉帧**
**修复方向**`splitBlocks` 内对全文长度 >50K 做采样或限制 blocks 数量;尾块超过阈值(如 5K 字符)改为增量 append 而非全文重算。
---
## 二、特殊字符场景
### P2-AXSS 注入 — 三层防护完备 ✅
- `escapeFallback`useMarkdown.ts:175&/</> HTML 实体转义 → marked 未就绪时纯文本安全。
- `DOMPurify.sanitize`useMarkdown.ts:188marked 输出后过滤 → 所有 `v-html` 输出必经此层。
- `highlightCode`useMarkdown.ts:99-107catch 异常降级 HTML 转义。
**结论**:无问题,无需修复。
---
### P2-BJSON 解析注入 — ToolCard parseResult 安全 ✅
`ToolCard.vue:287-293 parseResult()``try-catch`,非法 JSON 返回 null → 模板 `v-if parsed` 不渲染。
**结论**:无问题,无需修复。
---
### P2-CsimpleHash 碰撞 — 流式块 key 冲突
**定位**`AiChat.vue:884-888 simpleHash()` 使用 DJB2 算法32-bit 输出)
**风险论证**
- 已完成块的 key 用 `b-{simpleHash(blockText)}`key 稳定性依赖 hash 唯一性。
- 32-bit 哈希在 300 条缓存内有碰撞概率(生日悖论:~10⁻⁵ 级别,低但不为零)。
- **碰撞后果**:两块不同文本有相同 key → Vue 复用 DOM → 渲染错误内容。
- 实际影响极小(需要两块不同文本产生相同 32-bit hash + 同时出现在同一流式会话中),但零概率保证。
**修复方向**:改用 `block.text.slice(0, 40) + block.text.length` 做 key确定性唯一无碰撞风险
---
## 三、空值/缺失场景
### P3-A核心函数空值防护 — 完备 ✅
| 函数 | 空值处理 | 判定 |
|------|----------|------|
| `renderMd('')` | L181 提前返回 '' | ✅ |
| `renderStreamingBlocks('')` | L868 返回 [] | ✅ |
| `formatBytes(null/undefined/NaN)` | ToolCard L341 isFinite 兜底 '0 B' | ✅ |
| `formatArgValue(undefined/null)` | ToolCard L278 返回 '' / 特殊显示 | ✅ |
| `parseResult(null)` | ToolCard L288 返回 null | ✅ |
| `isNearBottom()` | 容器 ref 为空返回 true兜底认为在底部 | ✅ |
---
### P3-B`messagesContainer` undefined 时序 — 有守卫,但 watch 触发过早
**定位**`AiChat.vue:1978` `watch(() => store.state.messages.length, onContentChange)` 在 onMounted 前可能触发store 初始化时 messages 已有数据)。
`onContentChange → isNearBottom() → el = undefined → return true → scrollToBottom() → messagesContainer.value 为 undefined → 静默无操作`
**结论**:不会崩溃,但首次加载时自动滚底不生效(需等 onMounted 容器就绪后用户手动滚或新消息触发)。影响轻微,可不修复。
---
## 四、大量数据场景
### P4-A1000+ 条消息 — 多次全量遍历
**代码路径**
```
messageSegments computed → O(n) 遍历 messages
renderItems computed → O(n) 遍历 segments
buildActiveToolIds → O(n×t) 遍历 messages + toolCalls
lastMsgSnapshot watch → JSON.stringify O(n×t)
```
**定位**
- `AiChat.vue:2455 messageSegments` — 每条消息一次遍历
- `AiChat.vue:2511 renderItems` — 每条 segment 一次遍历
- `AiChat.vue:2052 buildActiveToolIds` — 双重循环 messages × toolCalls
- `AiChat.vue:2071-2079` deep watch — `JSON.stringify({n, tc: [...所有toolCalls的id+status...]})`**每次 deep watch 触发都全量序列化**
**已有优化**B-260616-13 已剔除 content.length流式 delta 不再触发 watch body 重算snap 不变)。✅
**剩余风险**:初始加载 1000+ 条消息时 `messageSegments` + `renderItems` 仍全量计算。虚拟滚动裁剪了渲染,但 computed 链仍全量算。
**修复方向**messageSegments 改为分页/窗口计算(配合虚拟滚动只算可见窗口附近)。
---
### P4-B虚拟滚动 — IntersectionObserver entries 批量处理 ✅
**定位**`useAiVirtualScroll.ts:67-90`
IO 回调中遍历 entries → `next.delete(key)` 操作 Set → `renderedKeys.value = next` 触发响应式更新。大批量 entries快速滚屏时一次性处理多条目但每条目是 O(1) Set 操作。
**结论**:合理,无性能瓶颈。唯一关注点:`pinnedKeys` 无上限但当前恒为单 key流式末条
---
## 五、数值边界场景
### P5-A`_mdCache` 全量 clear — 缓存雪崩
**定位**`useMarkdown.ts:189` `if (_mdCache.size > 200) _mdCache.clear()`
**风险论证**
- 不是 LRU 淘汰,而是**全部清空**。
- 场景201 条不同消息文本 → 第 201 条触发全清 → 随后任何历史消息再次渲染需重新 marked.parse + sanitize。
- 200 条的阈值对于长对话偏低200 轮往返=400 条消息)。
**修复方向**:改为 LRU 淘汰Map 已保证插入顺序,`_mdCache.delete(_mdCache.keys().next().value)` 就是 FIFO 淘汰一条而非全清)。
---
### P5-B`_blockCache` 逻辑 — FIFO 逐条淘汰,优于 _mdCache ✅
**定位**`AiChat.vue:856` `_blockCache.delete(_blockCache.keys().next().value!)`
这里用的是**逐条 FIFO 淘汰**(只删最老一条),比 `_mdCache` 的全清更合理。但 `BLOCK_CACHE_LIMIT=300` 且无 LRU高频文本块可能被误淘汰。影响轻微。
---
### P5-C其他边界值 — 均有兜底 ✅
| 项目 | 上限 | 状态 |
|------|------|------|
| `pendingImages` | 6 张 | ✅ |
| `mentionItems` | 各 20 条 | ✅ |
| `inputText` 无 maxlength | **无限制** | ⚠️ 可粘贴 1MB 文本,虽不崩溃但无提示 |
| `autoResize` | 120px max-height | ✅ |
---
## 六、深层嵌套场景
### P6-A模板条件嵌套深度 — 可维护性差但功能正确
**定位**AiChat.vue template L362-567
```
v-for renderItems
→ sentinel div (.ai-msg-slot)
→ v-if kind==='sep' (分隔条,始终渲染)
→ v-else-if shouldRenderMsg (消息体)
→ v-if role==='user'
→ 用户气泡+图片+编辑+时间戳
→ v-else (AI)
→ v-if streaming + currentText
→ v-for streamingBlocks (分块渲染)
→ v-else v-html renderContent (完成态)
→ v-else-if 流式无文本光标
→ 操作栏(复制/重试/重生成)
→ Token 用量
→ ToolCardList 工具卡
→ 时间戳
```
嵌套深度6 层。功能正确,但 3917 行单文件严重违反单一职责。
**修复方向**:建议拆分子组件:
- `AiMessageBubble.vue` — 单条消息气泡user/ai 双分支)
- `AiMessageStreaming.vue` — 流式渲染 + 选区保存恢复
- `AiConversationSidebar.vue` — 对话侧栏
---
## 七、ToolCard 特有问题
### P7-Aread_file 展开全部 — 50K 行全量 DOM
**定位**`ToolCard.vue:81` `<pre><code>{{ parsed?.content }}</code></pre>`
折叠态 `max-height:180px` + `overflow-y:auto` + fade 遮罩 → ✅ 安全。
**展开态**无任何限制 → 50K 行文件内容全部塞进单个 `<code>` 文本节点 → 布局计算耗时。
**修复方向**:展开态也加 `content.slice(0, 50000)` 截断 + "内容过长已截断"提示。
---
### P7-Blist_directory 无条目上限
**定位**`ToolCard.vue:92-100` `v-for="(entry, i) in parsed?.entries"` 无上限。
5000+ 条目 = 5000+ DOM 节点(每项含图标 SVG + 名称 + 大小)。
**修复方向**:截断至 500 项 + "还有 N 项未显示"提示。
---
### P7-CdiffLines 全量 v-for
**定位**`ToolCard.vue:45 + 567-577`
10000 行 diff → `diffLines` computed 全量 split + map → 10000 个 `<span>` DOM 节点。CSS `max-height:240px` 仅遮视觉DOM 全在内存。
**修复方向**`diffLines` computed 内截断至 1000 行 + 截断提示行。
---
## 八、问题汇总与优先级
| 编号 | 场景 | 等级 | 现象 | 修复方向 |
|------|------|------|------|----------|
| P0-A | 单条消息 >100K 字符完成态 | 🔴 | DOM 爆炸 + marked 同步阻塞 | `.ai-msg-bubble--ai``max-height:600px` + `overflow-y:auto` + "内容过长,展开查看"折叠 |
| P0-B | 流式生成超长消息 | 🔴 | 每帧全文 lexer + 尾块全量 parse | `splitBlocks` 内对全文长度 >50K 做采样或限制 blocks 数量 |
| P4-A | 1000+ 条消息初始化 | 🔴 | computed 链全量遍历 | messageSegments 改为分页/窗口计算(配合虚拟滚动只算可见窗口) |
| P7-A | read_file 展开全部 | 🟡 | 50K 行全量 DOM | 展开态截断 `content.slice(0,50000)` |
| P7-B | list_directory 无上限 | 🟡 | 5000+ DOM 节点 | `entries.slice(0,500)` |
| P7-C | diffLines 全量渲染 | 🟡 | 10000 行 DOM 节点 | `diffLines.slice(0,1000)` |
| P5-A | _mdCache 全量 clear | 🟡 | 缓存雪崩 | 改 LRU 逐条淘汰 |
| P2-C | simpleHash 碰撞 | 🟡 | 块级 key 冲突(极低概率) | 改用 `block.slice(0,40)+length` |
| P6-A | 3917 行巨型组件 | 🟡 | 可维护性差 | 拆分子组件 |
| P3-B | messagesContainer undefined 时序 | 🟢 | 首次加载不自动滚底 | 影响轻微,可不修复 |
---
## 九、已有防护措施汇总(无需修复)
| 措施 | 位置 |
|------|------|
| DOMPurify XSS 防护 | useMarkdown.ts:188 |
| escapeFallback 纯文本降级 | useMarkdown.ts:174-176 |
| formatBytes NaN/Infinity 兜底 | ToolCard.vue:341 |
| formatArgValue undefined/null 兜底 | ToolCard.vue:276-282 |
| parseResult try-catch | ToolCard.vue:287-293 |
| highlightCode try-catch | useMarkdown.ts:99-107 |
| read_file 折叠态 max-height:180px | ToolCard.vue:1074 |
| inputText autoResize max 120px | AiChat.vue:1802 |
| 虚拟滚动 sentinel 占位防塌 | useAiVirtualScroll.ts:79-82 |
| rAF 节流防掉帧 | AiChat.vue:897 |
| 流式末条 pinned 保活 | AiChat.vue:2539-2549 |
---
*分析完成日期2026-06-17*
*相关任务DevFlow AI Chat 组件极端数据场景多角度分析*

View File

@@ -113,11 +113,247 @@
**已审文件清单(本轮)**crates/df-ai-core/src/provider.rs · crates/df-ai/src/anthropic_compat.rs · crates/df-ai/src/openai_compat.rs · src-tauri/src/commands/ai/conversation.rs · src-tauri/src/commands/ai/commands.rs · src/api/ai.ts · src/api/types.ts · src/composables/ai/useAiSend.ts · src/composables/ai/useAiConversations.ts · src/stores/ai.ts · src/components/AiChat.vue图片构建+渲染) · src/components/settings/ProviderPanel.vue
**新登记 todo**B-260617-07 · B-260617-08
### 🔧 2026-06-17 aichat 消息全量重叠5角度深入分析·未实施
> 用户报 aichat 对话「大量重叠」「不单间距问题」。session-role-diagnose-only5 角度并行论证后记录。
**现象确认(基于截图)**
| 现象 | 判定 |
|---|---|
| 顶部出现 `<transition name="sidebar-slide">` 等模板源码文本 | ✅ 正常 — AI 回复中 read_file 读取 AiChat.vue 源码后 v-html 展示,非渲染泄漏 |
| 消息/工具卡大量重叠堆叠 | 🔴 异常 — 布局层叠问题 |
| 整体挤压、间距消失 | 🔴 异常 |
**已排除项**
| 检查点 | 结论 |
|---|---|
| CSS 声明链路 | ✅ `.ai-messages``display:flex;flex-direction:column;gap:14px`:3109-3116无外部覆盖 |
| scoped 样式冲突 | ✅ 单 `<style scoped>`global.css 无 ai-messages 覆盖 |
| markdown 渲染安全 | ✅ DOMPurify sanitize + escapeFallback 兜底useMarkdown.ts:181-192 |
| DOM 结构正确性 | ✅ `.ai-msg-slot``.ai-messages` 直接子元素gap 应生效 |
---
## 角度①:虚拟滚动 IntersectionObserver 时序与渲染逻辑
**Vue 3 ref 时序修正**`.ai-messages`(ref=messagesContainer) 是**父元素**,其 ref 先于 v-for 子元素 `.ai-msg-slot` 的 ref 回调赋值。所以 `registerSentinel` 执行时 `options.root.value` **理论上已就绪**IO/RO 应能正常创建和 observe。**假设「root 未就绪导致漏 observe」概率下调但仍不能排除——因为 Vue 3 的 ref 赋值时机是微任务队列中的异步操作v-for 子元素的 ref 回调可能在同一次微任务中但顺序不确定。
**关键执行路径核验**
```
registerSentinel(key, el) 执行流程:
① el instanceof HTMLElement? ✓ (DOM 已创建)
② el.dataset.vscrollKey = key
③ h = prev?.height ?? 0 → 首次 h=0
④ h>0? → 否,跳过 minHeight 设置
⑤ itemMeta.set(key, {el, height:0})
⑥ ensureObserver() → rootEl=options.root.value
- 若 rootEl 存在且 io 为空 → 创建 IO+RO → io.observe(el)+ro.observe(el) ✓
- 若 rootEl 为 null → 静默返回,io/ro 仍为 null → observe 全部跳过 ✗
⑦ renderedKeys 不含 key → add(key)
```
**如果路径 ⑥ 走了 ✓ 分支IO 正常创建)**
- IO 以 rootMargin:600px 观察 sentinel → 首屏所有 item 都在可见区 → 全部 add 到 renderedKeys → **所有消息渲染** → 此时不应有重叠
- RO 测量每个 sentinel 的真实高度 → itemMeta.height 被回填
- 滚动后不可见 item → IO 回调触发删除 → 设置 minHeight 锁高度 → 内容卸载(v-else-if=false) → slot 占位
**如果路径 ⑥ 走了 ✗ 分支rootEl 为 null**
- 所有 sentinel 的 io/ro observe 被跳过
- onMounted → setupVirtualScroll() → ensureObserver() → **此时 rootEl 就绪** → 创建 IO+RO
- **但已注册的元素未被重新 observe** setupOnMount 注释明确写"不主动全量 observe"
- IO 永远不触发回调 → renderedKeys 只增不减 → **所有消息始终渲染** → 单纯"虚拟化失效"不应导致重叠
- **除非后续操作**(消息新增/对话切换)触发 registerSentinel 重跑 → 此时 IO 已存在 → 新 item 被 observe → IO 开始工作 → 但旧 item 仍从未被 observe
**角度①结论**IO 漏 observe 导致**纯虚拟化失效**(全部渲染)不会直接造成重叠。但如果 IO 在某个时刻开始部分工作(如新增消息触发的 registerSentinel 让 IO 活跃起来),可能出现**不一致状态**:部分 item 被 IO 管理(可卸载)、部分不被管理(永远渲染)。这种半激活态下若发生卸载而 height=0RO 从未测过)→ minHeight 不设置 → slot 塌 0 → **可能重叠**。**概率:中(需特定时序条件触发)**。
---
## 角度②CSS 布局嵌套链路分析
**完整 flex 嵌套层级6 层)**
```
.ai-panel display:flex; flex-direction:row; height:100%
└─ .ai-chat-area position:relative; flex:1; display:flex; flex-direction:column; height:100%; min-width:0
├─ .ai-header (固定高度 ~44px)
├─ .provider-bar (~32px)
└─ .ai-messages flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:14px; padding:14px
└─ .ai-msg-slot display:flex; flex-direction:column ← UX-19 新增 wrapper
└─ .ai-msg (role-based class)
├─ .ai-msg-user display:flex; gap:8px; align-items:flex-start; flex-direction:row-reverse
└─ .ai-msg-ai display:flex; gap:8px; align-items:flex-start
└─ .ai-msg-content flex:1; min-width:0; display:flex; flex-direction:column; gap:8px
├─ .ai-msg-bubble (内容)
├─ .ai-msg-actions (按钮组)
├─ ToolCardList (工具卡列表)
└─ .ai-msg-time (时间戳)
```
**全局规则影响**
- `* { margin:0; padding:0; box-sizing:border-box }`global.css:100→ 消除浏览器默认 margin/padding对 flex gap 计算无负面影响
- `html,body,#app { overflow:hidden }`:105→ 根级不滚动,`.ai-messages``overflow-y:auto` 独立滚动,**这是正确的**
- **无冲突点**
**UX-19 改前 vs 改后对比**
| | 改前 | 改后 |
|---|---|---|
| `.ai-messages` 直接子元素 | `.ai-msg` / `.ai-msg-segment` | `.ai-msg-slot`(新 wrapper |
| gap 作用对象 | 消息/分隔条本身 | slot 容器 |
| slot 内部布局 | N/A | `display:flex;flex-direction:column` |
**slot 作为额外 flex 容器的影响**`.ai-msg-slot { display:flex;flex-direction:column }``.ai-msg` 变为 flex item。由于 `.ai-msg` 自身也是 flex 容器user/ai 方向),这增加了一层 flex 格式化上下文。在标准浏览器中这不应导致问题,但增加了嵌套深度到 **7 层**
**角度②结论**CSS 声明链路干净无全局覆盖。UX-19 多一层 slot wrapper 增加嵌套深度但在标准 Chromium 中不导致重叠。**概率:低(非根因)**。
---
## 角度③:消息数据→渲染一致性分析
**renderItems 生成逻辑**:2511-2527
```typescript
// messageSegments computed → 按 status 分段(normal/archived/compressed)
// renderItems computed → 扁平化:
// normal 段 → { kind:'msg', key:'m-'+msg.id, msg }
// 折叠段 → { kind:'sep', key:seg.key, seg } + 展开时追加 { kind:'msg', key:'e-'+seg.key+'-'+msg.id, msg }
```
**key 唯一性**
- normal 段 key = `'m-' + msg.id` → msg.id 全局唯一 → key 唯一 ✅
- 展开折叠段 key = `'e-' + seg.key + '-' + msg.id` → seg.key 含 msg.id 前缀 → 组合唯一 ✅
- sep key = `'seg-' + startMsgId → 按 status 连续分组 → key 唯一 ✅
- **无重复 key 风险** ✅
**切换对话时的清理**
- `switchConversation` 替换 `store.state.messages` → messageSegments 重新计算 → renderItems 全部重建
- Vue 的 v-for + :key 机制:旧 key 对应的 DOM 元素被销毁 → 触发旧 sentinel 的 `registerSentinel(key, null)` 卸载分支 → io.unobserve + ro.unobserve + itemMeta 保留 height
- **清理链路完整** ✅
**流式生成中新增消息**
- 新消息 push 到 store.state.messages → messageSegments 更新 → renderItems 追加新 item → 模板 v-for 挂载新 `.ai-msg-slot` → registerSentinel(newKey, newEl)
- 此时 IO/RO 已存在onMounted 早已执行)→ ensureObserver 直接返回io 非 null→ io.observe(newEl) + ro.observe(newEl) **✅ 正常**
- **新增消息路径无问题** ✅
**角度③结论**:数据→渲染链路 key 唯一、切换清理完整、流式新增正常。**概率:极低(非根因)**。
---
## 角度④ToolCard 内部布局对父级影响
**ToolCard 绝对定位元素清单**
| 选择器 | 位置 | 用途 | 影响 |
|---|---|---|---|
| `.ai-tool-file-pre--collapsed::after` | :1080-1089 | 文件预览渐变遮罩(48px高) | `position:absolute; bottom:0` — 伪元素脱离文档流 |
| `.ai-sidebar-resize-handle` | AiChat.vue:2641-2649 | 侧栏拖拽条 | `position:absolute; height:100%` — 在 sidebar 上,不影响 messages |
**关键分析:`.ai-tool-file-pre--collapsed::after`**
- 父元素 `.ai-tool-file-pre--collapsed` 设了 `position:relative`:1078→ absolute 伪元素相对于它定位
- 伪元素 `height:48px` 覆盖底部,**但不撑高父元素**absolute 元素不参与父级高度计算)
- **然而**`.ai-tool-file-pre--collapsed` 本身有 `max-height:180px; overflow-y:auto`:1074-1077→ 内容超出 180px 时内部滚动 → 父元素高度锁定 180px → **不受 absolute 伪元素影响**
- **结论:此 absolute 伪元素不会导致父级高度塌陷** ✅
**ToolCardList 布局**ToolCardList.vue:282-284
```css
/* ToolCardList 容器 */
display: flex;
flex-direction: column;
gap: 8px;
```
- 每个 ToolCard 是 flex item高度由内容决定
- 折叠态 ToolCard 用 `v-show`(非 v-if`display:none` 彻底移出流 → 不占空间 ✅
- running/pending_approval 强制展开 → ToolCard 可见 → 占据实际高度
**极端场景模拟**:单条 AI 消息含 **N 个 ToolCard**(截图场景看起来有 10+ 个工具调用):
- 每个 ToolCard 含 header(~36px) + body(文件预览/参数/结果, 可能 200-500px) + footer
- 单条 AI 消息总高度可能达 **2000-5000px**
- `.ai-msg-content`(flex-direction:column) 正确包含全部高度 ✅
- `.ai-msg-ai`(flex) 正确包含 content ✅
- `.ai-msg-slot`(flex-direction:column) 正确包含 `.ai-msg`
**角度④结论**ToolCard 内部 absolute 定位有 `position:relative` 父容器约束,不会导致高度塌陷。工具卡密集导致的单条消息极高在 flex 嵌套中能正确传播高度。**概率:低(非根因)**。
---
## 角度⑤Tauri/WebView2 特有问题
**WebView2 版本**Tauri v2 使用系统安装的 WebView2 Runtime非捆绑。Windows 11 默认 WebView2 版本 ≥ 91flex gap 在 84+ 完整支持。
**已知 WebView2 与 flex 相关问题**
1. **Chromium bug #1143747**:深层嵌套 flex + gap 在某些情况下 gap 不生效 → **但我们的 gap:14px 在 `.ai-messages` 上仅 1 层嵌套,不太可能命中**
2. **DPI 缩放**Windows 125%/150% 缩放下 flex 布局的像素计算可能有亚像素舍入误差 → 导致 1px 级别的偏移,**不足以解释大规模重叠**
3. **IntersectionObserver rootMargin**WebView2 的 IO 实现与标准 Chrome 一致(共享 Blink 引擎rootMargin:600px 应正常工作
4. **ResizeObserver + IO 同时观测同一元素**规范允许Chromium 正确处理,无竞态问题
**Tauri 特殊配置**:检查 tauri.conf.json 中是否有 webview 相关配置可能影响渲染。
**角度⑤结论**WebView2 在 Windows 11 上与标准 Chrome 行为一致,无已知 bug 能解释此现象。**概率:极低(非根因)**。
---
## 综合判定与新假设
**5 角度逐一排除后原假设IO 漏 observe / ToolCard absolute / CSS 嵌套 / 数据一致 / WebView2均无法充分解释「全量重叠」现象。**
**需要补充排查的方向**
**🔴 新假设 A [最高概率]:虚拟滚动 `shouldRenderMsg` 返回 false 导致内容卸载,但 slot 的 minHeight 未正确设置 → 0 高 slot 堆叠**
具体路径:
1. IO 正常工作角度①修正rootEl 就绪时 IO 成功创建)
2. 某些 sentinel 在 IO 首次回调时被判为**不可见**(如页面尚未完成 layout或 rootMargin 计算偏差)
3. IO 回调进入卸载分支(:68-76`meta.height > 0` 检查
4. **此时 RO 尚未触发**RO 的首次回调通常比 IO 晚,因为 RO 需要等内容渲染完成后才测得尺寸)→ `meta.height` 仍为初始值 **0**
5. `meta.height > 0` 为 false → **不设置 minHeight** → renderedKeys 中删除该 key
6. `shouldRenderMsg(key)` 返回 false → `v-else-if` 不渲染内容
7. `.ai-msg-slot` 内部无内容 → **高度塌为 0**padding/margin 均为 0仅靠 gap 撑开)
8. 下一条消息的 slot "坐落" 在上一条的 0 高 slot 上 → **视觉重叠**
**关键证据支持**
- useAiVirtualScroll.ts:73 `if (meta && meta.height > 0 && meta.el.isConnected)`**height=0 时跳过 minHeight 保护**,这正是漏洞
- RO 首次回调 timing 不保证在 IO 之前 → 存在 **IO 先于 RO 触发** 的竞态窗口
- 一旦某个 item 的 height 被错误地记为 0或从未被 RO 测量),后续即使重新可见(:62-66 清 minHeight如果再次不可见而 height 仍为 0 → 再次不设 minHeight → **持续塌陷**
**🟡 新假设 B [中等概率]`v-else-if` 条件渲染导致 Vue 复用 DOM 节点错乱**
模板结构:
```html
<div class="ai-msg-slot">
<div v-if="item.kind === 'sep'" ...> <!-- 分隔条 -->
<div v-else-if="shouldRenderMsg(item.key)" ...> <!-- 消息内容 -->
<!-- 无 v-else → 两者都不满足时 slot 内为空 -->
</div>
```
`shouldRenderMsg` 在 true/false 间快速切换IO 回调频繁触发时Vue 的 v-if/v-else-if DOM 复用机制可能在极端情况下产生**短暂的 DOM 结构不一致**(如旧节点未完全销毁即被复用)。但这通常是瞬态的,不应导致持久重叠。
**🟡 新假设 C [中等概率]markdown v-html 输出含未闭合标签破坏 DOM 结构**
虽然 DOMPurify sanitize 应防止此问题,但如果 AI 回复的内容(经 marked 解析后)产生了异常 HTML`<div>` 未闭合被 Purify 截断),可能导致:
- v-html 注入的 HTML 破坏 `.ai-msg-bubble` 的闭合标签
- 后续兄弟元素被"吞入" bubble 内部
- **多条消息的 DOM 树结构错乱** → 视觉重叠
这与截图现象吻合(代码块内容溢出到相邻消息区域)。
---
**最终优先级排序**
| 优先级 | 假设 | 概率 | 验证方法 |
|---|---|---|---|
| **P0** | **A: IO 先于 RO 触发 → height=0 → minHeight 跳过 → slot 塌陷** | **高** | DevTools 断点 IO 回调检查 meta.height或在 :73 前加 `console.log` |
| P1 | C: v-html DOM 破坏 | 中 | DevTools 检查 `.ai-msg-bubble` 的 innerHTML 是否有未闭合标签 |
| P2 | B: v-else-if 快速切换 DOM 复用异常 | 低 | 临时改 v-else-if 为 v-else始终渲染验证 |
| P3 | 原①: IO 漏 observe | 低→中 | 已修正为"IO 正常但时序竞态" |
- [ ] B-260617-09 [P1] — **aichat 消息全量重叠**。用户报「大量重叠」「不单间距」。5 角度深入论证排除 CSS/markdown/数据/WebView2 后,**最高概率根因更新为:虚拟滚动 IO 与 RO 时序竞态**——IO 先于 RO 首次回调触发卸载分支时 `itemMeta.height` 仍为 0 → `:73` `height>0` 守卫失败 → 不设 minHeight → slot 塌 0 高 → 下条消息上浮重叠。次 probablev-html DOM 破坏marked+purify 产出异常 HTML 破坏 bubble 闭合标签)。**修法方向**①IO 卸载分支加 fallback minHeight如取 el.offsetHeight 或固定最小值 40px②临时禁用虚拟滚动验证根因 ③DevTools 断点验证 IO/RO 时序。— src/composables/ai/useAiVirtualScroll.ts(:68-76 IO卸载分支 height守卫) + src/components/AiChat.vue(:400-401 shouldRenderMsg条件渲染) + src/composables/useMarkdown.ts(:188 renderMd sanitize)
### 🔧 2026-06-17 useAiSend.ts 代码审查(仅走查·未实施)
> 排查性质session-role-diagnose-only对照代码走查 `src/composables/ai/useAiSend.ts`456 行。波2 已实施 UX-05/06/07 + F-01 modelOverride本次基于**当前代码**核验(不信 todo 声明UX-05/06/07 经代码确认一致 ✅)。仅记录待办,未改代码。
- [ ] B-260617-01 [P2] — **sendQueuedNow「立即发送」可能退化为入队**。审查发现sendQueuedNow(:406-411) `await stopChat()` 仅等 stop IPC 发出,不等后端 loop 退出 / `ai_is_generating` 复位。紧接 `await sendMessage(spliced)` 时,后端 stop_flag 已置但 generating 可能仍 trueloop 未跑到 agentic.rs:178 检测点 / guard.reset():188 未执行sendMessage 命中 L1(:281 `backendGenerating || streaming`) → spliced 被**入队而非立即发**与「立即」语义不符。UX-07 条目原标「stop_flag 复位降级可接受」,此审查**质疑该结论**——降级实为功能不达预期。**待核验**`ai_is_generating` 复位时机stop_flag→loop:178→guard.reset:188 链路延迟若滞后sendQueuedNow 应走 forceMode 跳过预检或等 AiCompleted 再发。— src/composables/ai/useAiSend.ts:406-411
- [x] ✅(workflow wfryptv2t·代理D核验并修·2026-06-17·forceMode=true) B-260617-01 [P2] — **sendQueuedNow「立即发送」可能退化为入队**。代理D独立核验:stopChat 仅 await stop IPC 发出(stop_flag.store),不等后端 loop 跑到 agentic.rs:444/:819 检测点+guard.reset()(generating 才复位),紧接 sendMessage(forceMode=false) 命中 L1 ai_is_generating(后端真值仍 true)→ 入队而非立即发。**修**:sendQueuedNow forceMode false→true(useAiSend.ts:472),跳 L1 走 doSend→ai_chat_force_send(commands.rs:742-748)原子复位 generating=false 再发,无竞态窗口;stop_flag 让旧 loop 下个检测点退出,force_send stop_flag 同向不冲突;parts 透传保留。vue-tsc EXIT 0。审查发现sendQueuedNow(:406-411) `await stopChat()` 仅等 stop IPC 发出,不等后端 loop 退出 / `ai_is_generating` 复位。紧接 `await sendMessage(spliced)` 时,后端 stop_flag 已置但 generating 可能仍 trueloop 未跑到 agentic.rs:178 检测点 / guard.reset():188 未执行sendMessage 命中 L1(:281 `backendGenerating || streaming`) → spliced 被**入队而非立即发**与「立即」语义不符。UX-07 条目原标「stop_flag 复位降级可接受」,此审查**质疑该结论**——降级实为功能不达预期。**待核验**`ai_is_generating` 复位时机stop_flag→loop:178→guard.reset:188 链路延迟若滞后sendQueuedNow 应走 forceMode 跳过预检或等 AiCompleted 再发。— src/composables/ai/useAiSend.ts:406-411
- [x] ✅(小修批·2026-06-17·workflow wdqxhw4x1+主代修回填/文案) B-260617-02 [P2] — **drainQueue 单条失败致队列卡死 + 错误吞没**。drainQueue(:247-251) `void sendMessage(...)` fire-and-forgetdoSend IPC 失败 throw(:113) 被 void 忽略 → 无 AiCompleted 触发下次 drain → 剩余队列**永久卡住** + 用户无错误反馈。**修法**drainQueue catch 失败emit 错误提示 + 决定续发下一条或终止。— src/composables/ai/useAiSend.ts:247-251
- [x] ✅(小修批·2026-06-17·wdqxhw4x1) B-260617-03 [P3] — **lang 解析三处重复DRY**。doSend(:94-97) / regenerate(:151-154) / editMessage(:224-227) 各一份相同 `df-ai-language`→auto 回落 `df-language` 解析。**修法**:提取 `resolveLang(): string` 辅助函数,三处复用。— src/composables/ai/useAiSend.ts
- [x] ✅(小修批·2026-06-17·wdqxhw4x1) B-260617-04 [P2] — **tryForceSend 失败后消息丢失无提示**。tryForceSend(:301-318) force_send 失败 catch 返 false(:316),但队首已 shift(:307) → 消息**丢失**,调用方是否提示用户未核验。**修法**:失败回填输入框或 toast对齐 doSend 失败回填:108-113 模式)。— src/composables/ai/useAiSend.ts:301-318
@@ -157,7 +393,7 @@
- [ ] UX-260616-01 [P2] — **工具调用失败时「重试」提示语义模糊**。用户场景:Run Command 执行失败(exit_code 255,`head` not recognized on Windows),UI 显示「⚠ 调用失败,正在重试(1/4)…」+「重试」按钮。**根因分析**:「正在重试(n/m)」是 `AiStreamRetry` 事件(F-260616-07 流式 LLM 重试),通过 `useAiEvents.ts:177-186` 更新错误气泡内容;工具执行失败(run_command 等 Low 风险工具)走 AR-6 路径(`audit.rs:640-652`)→ emit `AiToolCallCompleted`(result=错误信息)→ **不触发 AiError 不触发 AiStreamRetry**,错误包在 tool_result 回传 LLM 自行决策。**问题**:①用户看到的是「LLM 流重试」提示,非「工具执行失败」提示,语义错位 ②确定性失败(命令语法错/exit_code 非0)重试同命令必再败,「重试」按钮误导 ③工具实际结果(stdout/stderr/exit_code)已显示在 ToolCard 内,错误气泡的「重试」是消息级 regenerate(重新生成整条回复),非工具级重试。**改动方向**(待定):a)错误气泡区分两类——流式重试中显示「正在重试(n/m)…」(现有);工具执行失败不弹错误气泡(结果已在 ToolCard)或气泡文案改为「工具执行失败,查看上方结果」b)Fatal 类错误(4xx/鉴权/参数)气泡去掉「重试」按钮或改为「去设置」c)`AiStreamRetry` 事件更新气泡时附带 `retryable` 标识,前端据此显隐重试按钮。—— useAiEvents.ts(:177-186 AiStreamRetry case) + AiChat.vue(错误气泡 UX-03 操作栏) + i18n ai.aiStreamRetry
- [ ] 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 触发时机扩展)
- [x] ✅(workflow wfryptv2t·代理A核验已落地·2026-06-17·主代独立grep核验非误判) UX-260616-03 [P2] — **对话内容输出时自动收起旧工具卡片分组**。**已落地**:①触发时机扩展——AiChat.vue:2093 agentRound watch(AiAgentRound 新轮次→collapseAllToolLists(buildActiveToolIds))收起上一轮;既有 messages deep watch(:2085)覆盖新消息追加+toolCall状态翻转;触发链 useAiEvents.ts:169 `if(event.round>0) state.agentRound=event.round` + stores/ai.ts:75 agentRound字段。②collapseInactive增强——ToolCardList.vue:71 userExpandedCards 记忆态 Set,:180 isCardExpanded 优先读它(用户主动展开不被自动收起),:220 collapseInactive 仅清 expandedCards/expandedTools 不清 userExpandedCards。③CSS过渡留TODO——:373 注释:flex gap对零高item贡献双倍间距+auto→0不可纯CSS,需Transition JS钩子,属单卡级折叠重构,本轮不做(不影响功能收起)。需求:当 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 触发时机扩展)
- [x] ✅(batch66·2026-06-16·22d866d,formatToolResult 12工具覆盖+i18n 18key) 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)
- [x] ✅(波2·2026-06-16·w76d81dno+主代核查,vue-tsc,决策a逐条续发) 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 已就绪)。
- [x] ✅(波2·2026-06-16·w76d81dno+主代核查,vue-tsc,决策只编text) 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。
@@ -506,7 +742,7 @@
- [x] B-03b-R6 ~~human_node send 缺 await~~ ✅ 已修human_node.rs:41 加 .awaitasync fn send 的 Future 不再被 let _ = 丢弃Request 真进 channel(commit 0bb96fc)
- [x] B-03b-R7 ~~前端契约失配~~ ✅ 已修project.ts:214 type→snake_case + :215 取 event 本体扁平字段 + as unknown as 绕过联合类型)(commit 0bb96fc)types.ts event.type 收窄字面量联合(WorkflowEventType 11 变体)已补(commit 3e1f119 Wave5)
- [x] B-03b-R8 ~~缺 human 节点端到端集成测试~~ ✅ 已补human_node.rs 加 2 端到端测:`end_to_end_human_approval_completes_workflow` 验 a(SleepNode)→b(HumanNode) 两层 DAG 经 executor 驱动 Request 真发出 + outputs 收集 + 双节点 Completed`end_to_end_human_approval_cancelled` 验外部 set_cancelled → human cancel_tick 命中 → executor 跳过 set_failed + 状态保持 Cancelled。覆盖 executor↔HumanNode 集成链路,封死 R6/R7 回归土壤10 测全过2026-06-16待commit
- [ ] B-03b-R9 — **[P2]** 其余 8 项对抗裁定(③串扰/④单槽/⑤终态不清/⑥set_cancelled覆盖终态/⑦互斥/⑧approve不校验/⑨⑩Lagged/⑪failed_node空多数潜伏或零危害,详见 [审查报告 §2](./02-架构设计/工作流审批审查报告-2026-06-14.md)
- [x] ✅(workflow wfryptv2t·代理C独立Read+Grep核验·2026-06-17·不信报告结论) B-03b-R9 — **[P2]** 其余 8 项对抗裁定(③串扰/④单槽/⑤终态不清/⑥set_cancelled覆盖终态/⑦互斥/⑧approve不校验/⑨⑩Lagged/⑪failed_node空。**7项销账**:③串扰✅(workflow.rs:167-172 exec_id双重匹配治本)⑤终态不清✅(workflow.ts:57-70三变体监听+exec_id限定+Lagged兜底补发)⑥set_cancelled覆盖终态✅(workflow.rs:497-517 IPC终态前置守卫)⑦互斥✅(workflow.ts:8-11 _approvalInFlight单例approve/cancel共享)⑧approve校验✅(workflow.rs:431-450三层防御+human_node.rs:106-111下游兜底)⑨⑩Lagged✅(human_node.rs:153-161续等+workflow.rs:191-263累计阈值查DB终态兜底)⑪failed_node空✅(workflow.rs:355-369 snapshot取首个Failed/Cancelled节点id)。**1项留观察**:④单槽——state.ts:23 pendingApproval仍单值非Map,零危害(前端唯一DAG=demoDag无human节点入口+UI无并发工作流入口+⑤已按exec_id限定清理+后端HumanNode按exec_id+node_id各独立select!),待并发工作流入口落地时升Map。详见 [审查报告 §2](./02-架构设计/工作流审批审查报告-2026-06-14.md)
### ✅ 已完成 — df-workflow 审批闭环Workflow D, commit 22964a2
@@ -845,6 +1081,109 @@
> **统计**🔴2(P1 确定性 bug) 🟡3(P2 DRY/优化) ⚪3(P3 风格) + 1 评估维持。总体评级「良」:架构清晰、契约闭合、注释扎实,两处 🔴 低风险一行/小改可根治。详见本轮 /review 输出。
### 🔧 2026-06-17 全量代码缺陷扫描Rust后端+DAG引擎·仅走查·未实施
> 周期性全量缺陷扫描session-role-diagnose-only。Rust 后端 11 项已完成Vue 前端 + DAG 引擎因 API 速率限制失败待重跑aichat 交互层扫描进行中提高优先级结果追加到下方「aichat 交互体验」专区)。
>
> **论证原则**:每条经 file:line 源码佐证 + 触发条件分析 + 修复风险评估。只记录确定性 bug 或高概率问题。
#### 🔴 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
#### 🟡 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
#### ⚪ 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
---
### 🎯 aichat 交互体验缺陷(高优先级专区)
> 用户指令aichat 界面交互体验问题提高优先级。本区集中记录 AiChat.vue + ToolCard.vue + composables/ai/* 交互层缺陷。
> 扫描 agent 运行中,结果持续追加。已存在问题来自用户实测 + 历史走查。
#### 🔴 已确认交互 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
#### 🟡 待扫描确认的交互问题aichat agent 结果回填后补充/降级)
> 以下条目等 aichat 交互层缺陷扫描 agent 完成后,根据实际发现:
> - 确认存在 → 补充 file:line + 触发条件 + 修复建议
> - 不存在或极低概率 → 标记 ✅ 排除
> - 新发现 → 追加新条目
- [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-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
- [ ] **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
#### ⚪ aichat 扫描新增发现LOW · 8 项)
- [ ] **UX-260617-20 [P3]🎯****base64 图片 parts 无大小预算200 条消息上限不感知单条体积** — MESSAGE_CAP=200 仅按条数 splice单条 base64 可达数 MB。极端 200条×5MB=~1GB 全挂 reactive state。**修法**:增加基于序列化大小的预算(total parts size<50MB),超出裁剪最旧消息 parts。— src/api/types.ts:228-243 + src/stores/ai.ts:78-87
- [ ] **UX-260617-21 [P3]🎯****Markdown 表格溢出气泡容器**`.ai-msg-bubble` 缺表格专属溢出处理。AI 返回≥4列表格撑破宽度溢出到侧边栏。**修法**`.ai-md table { display:block; overflow-x:auto; max-width:100% }`。— src/components/AiChat.vue CSS (~L3259)
- [ ] **UX-260617-22 [P3]🎯****空内容 AI 回复渲染不可见气泡**`AiChat.vue:457` v-if 条件使空 content 已完成消息整个 bubble 不渲染 → avatar+一片空白。**修法**:空 content 显示最小化占位`(empty)`或隐藏整条含 avatar。— src/components/AiChat.vue:457
- [ ] **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
- [ ] **UX-260617-27 [P3]🎯****ConfirmDialog 危险按钮默认标签"删除"语义不安全**`ConfirmDialog.vue:9-10` 默认 `$t('common.delete')`。非删除场景(高危工具审批)忘传 dangerLabel → 按钮显示"删除"。**修法**:默认值改为 `$t('common.confirm')`。— src/components/ConfirmDialog.vue:9-10
#### 架构观察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
---
### 📊 aichat 交互缺陷扫描统计2026-06-17
| 严重度 | 数量 | 编号范围 |
|--------|------|----------|
| 🔴 P0 | 1 | UX-01 (消息重叠) |
| 🟡 P1 | 9 | UX-02~11,13 |
| ⚪ P2 | 6 | UX-14~19 |
| LOW | 8 | UX-20~27 |
| INFO | 1 | UX-28 |
| **总计** | **25** | (含 4 项历史记录提升优先级 + 21 项新扫/确认) |
**Top 5 推荐 immediate fixROI 排序)**
1. **BUG-01** (`|| true` → false) — Rust 后端,一行改动消除无效重试
2. **UX-09** (startListener 异常吞噬) — 3 行 try/catch防止事件黑洞
3. **UX-10** (AiError 不清 pendingApprovals) — 一行赋值消除残留 UI
4. **UX-11** (modelOverride 泄漏) — 注释已有要求,只缺实现
5. **UX-03** (copyMsgContent 误报) — catch 分支改文案
---
## 长期 / 待需求驱动(不进看板主线)
- 裁剪/压缩消息按需召回Query Function + 分层存储)