|
| 1 | +# 工具调用 UX 优化 — 技术交接 |
| 2 | + |
| 3 | +> 产品思考见 [docs/insights/tool-call-ux.md](../insights/tool-call-ux.md) |
| 4 | +> 竞品调研见 [docs/research/tool-call-ux-competitive-analysis.md](../research/tool-call-ux-competitive-analysis.md) |
| 5 | +
|
| 6 | +## 概述 |
| 7 | + |
| 8 | +对 CodePilot 工具调用展示层的全面升级,覆盖 thinking 展示、工具分类渲染、上下文归组、状态动画、流式优化等。基于 Claude Code / CraftAgent / Opencode / Codex 四个竞品的调研结论实施。 |
| 9 | + |
| 10 | +## 改动清单 |
| 11 | + |
| 12 | +### 1. Thinking Display 全链路 |
| 13 | + |
| 14 | +**数据流:** |
| 15 | +``` |
| 16 | +claude-client.ts (thinking delta → SSE 'thinking') |
| 17 | + → useSSEStream.ts (onThinking callback) |
| 18 | + → stream-session-manager.ts (accumulatedThinking + phase separation) |
| 19 | + → ChatView.tsx → MessageList.tsx → StreamingMessage.tsx |
| 20 | + → ToolActionsGroup.tsx (ThinkingRow component) |
| 21 | +``` |
| 22 | + |
| 23 | +**关键文件:** |
| 24 | + |
| 25 | +| 文件 | 改动 | |
| 26 | +|------|------| |
| 27 | +| `src/lib/claude-client.ts` | 两处 `stream_event` handler 转发 `thinking_delta`;retry 路径补齐 content/is_error/media | |
| 28 | +| `src/types/index.ts` | `SSEEventType` + `'thinking'`;`SessionStreamSnapshot` + `streamingThinkingContent`;`MessageContentBlock` + thinking 变体 | |
| 29 | +| `src/hooks/useSSEStream.ts` | `SSECallbacks.onThinking`;`handleSSEEvent` case `'thinking'` | |
| 30 | +| `src/lib/stream-session-manager.ts` | `accumulatedThinking` / `fullThinking` / `thinkingPhaseEnded` 三层累积;phase 分隔(text/tool_use 到来时重置当前 phase) | |
| 31 | +| `src/components/ai-elements/tool-actions-group.tsx` | `ThinkingRow` 组件:Brain 图标 → hover 变 CaretRight 可展开;Streamdown 渲染内容 | |
| 32 | + |
| 33 | +**持久化路径:** |
| 34 | +- 客户端:`finalMessageContent` 序列化时包含 `{ type: 'thinking', thinking: allThinking }` block |
| 35 | +- 服务端:`/api/chat/route.ts` 的 `collectStreamResponse` 累积 `thinkingText` 并 unshift 到 `contentBlocks` |
| 36 | +- 历史渲染:`MessageItem.tsx` 的 `parseToolBlocks` 提取 thinking,传给 `ToolActionsGroup.thinkingContent` |
| 37 | +- 归一化:`message-normalizer.ts` 识别 thinking block,生成 `(reasoning: summary)` 摘要 |
| 38 | + |
| 39 | +**Phase 分隔机制:** |
| 40 | +多轮工具调用场景中,SDK 会在每轮产生新的 thinking block。`thinkingPhaseEnded` 标记在 text/tool_use 到来时设为 true,新 thinking delta 到来时将当前 thinking 保存到 `fullThinking`(用 `---` 分隔),重置 `accumulatedThinking`。UI 只显示当前 phase,持久化保存全部 phases。 |
| 41 | + |
| 42 | +**展开时防跳动:** |
| 43 | +`ThinkingRow` 展开时调用 `useStickToBottomContext().stopScroll()` 脱离自动滚动模式,避免内容展开触发 `use-stick-to-bottom` 的 resize 自动滚动。 |
| 44 | + |
| 45 | +### 2. ToolActionsGroup 重构 |
| 46 | + |
| 47 | +**工具注册表(ToolRegistry):** |
| 48 | + |
| 49 | +```typescript |
| 50 | +interface ToolRendererDef { |
| 51 | + match: (name: string) => boolean; |
| 52 | + icon: Icon; |
| 53 | + label: string; |
| 54 | + getSummary: (input: unknown) => string; |
| 55 | + renderDetail?: (tool: ToolAction, streamingOutput?: string) => ReactNode; |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +内置 5 种渲染器(bash/edit/read/search/fallback),通过 `registerToolRenderer()` 可扩展。替代了原来的 `getToolCategory` / `getToolIcon` / `getToolSummary` 硬编码函数。 |
| 60 | + |
| 61 | +**上下文工具归组:** |
| 62 | +`computeSegments()` 线性扫描 tools 数组,连续 3+ 个 CONTEXT_TOOLS(read/glob/grep/list/search)合并为 `ContextGroup`。2 个不归组。ContextGroup 有独立的展开/折叠和 StatusDot。 |
| 63 | + |
| 64 | +**Bash 实时输出:** |
| 65 | +`renderDetail` 在 running 时显示 `streamingToolOutput` 最后 5 行(滚动窗口),完成后显示 `tool.result` 前 20 行。`streamingToolOutput` prop 去掉了原来的 `_` 前缀启用。 |
| 66 | + |
| 67 | +**Flat 模式:** |
| 68 | +`flat` prop 跳过 header 和折叠动画,直接渲染工具列表。目前未使用但保留作为扩展点。 |
| 69 | + |
| 70 | +**Header 布局:** |
| 71 | +内容(数量 badge + 摘要 + 运行描述)在左,CaretRight 箭头在右(`ml-auto`)。 |
| 72 | + |
| 73 | +### 3. 状态动画 |
| 74 | + |
| 75 | +`StatusDot` 用 `AnimatePresence mode="wait"` 包裹三个状态: |
| 76 | +- running: opacity fade(SpinnerGap + animate-spin) |
| 77 | +- success: spring scale 弹入(stiffness 400, damping 20) |
| 78 | +- error: 同上 |
| 79 | + |
| 80 | +Header 运行描述用 `Shimmer` 包裹闪烁。 |
| 81 | + |
| 82 | +### 4. is_error 全链路修复 |
| 83 | + |
| 84 | +``` |
| 85 | +claude-client.ts (tool_result SSE 包含 is_error) |
| 86 | + → useSSEStream.ts (提取 is_error) |
| 87 | + → stream-session-manager.ts (透传到 snapshot + finalMessageContent) |
| 88 | + → ToolActionsGroup (StatusDot 显示红色 XCircle) |
| 89 | +``` |
| 90 | + |
| 91 | +同步修复了 `page.tsx` 和 `route.ts` 中的本地 ToolResultInfo 类型。 |
| 92 | + |
| 93 | +### 5. 流式优化 |
| 94 | + |
| 95 | +**智能文本缓冲(`useBufferedContent` hook):** |
| 96 | +- 40 词阈值或 2.5s max timeout 后释放 |
| 97 | +- 结构化块(show-widget/batch-plan/image-gen-request)旁路直通 |
| 98 | +- Timer 锚定到首次内容到达(`hasContent` boolean gate),不随 delta 重建 |
| 99 | +- 所有 bypass 决策通过 `useEffect` 执行,render 路径纯计算 |
| 100 | + |
| 101 | +**自适应文本节流(stream-session-manager):** |
| 102 | +- `throttledTextEmit()`:100ms 节流,非 text 事件(tool_use 等)触发 `flushTextThrottle()` |
| 103 | +- 定义在 try/catch 外层,error path 也能访问 |
| 104 | + |
| 105 | +### 6. Diff Summary |
| 106 | + |
| 107 | +`DiffSummary` 组件在 `MessageItem.tsx` 中:从 pairedTools 提取 edit/write 工具的文件路径,去重后显示 "Modified N files" 折叠行。 |
| 108 | + |
| 109 | +## 测试覆盖 |
| 110 | + |
| 111 | +| 文件 | 覆盖点 | |
| 112 | +|------|--------| |
| 113 | +| `__tests__/unit/sse-stream.test.ts` | thinking 事件分发、不混入 text、is_error 提取、media 透传 | |
| 114 | +| `__tests__/unit/message-normalizer.test.ts` | thinking-only 摘要、heading 提取、组合消息、长文本截断 | |
| 115 | + |
| 116 | +## 已知局限 |
| 117 | + |
| 118 | +- Thinking 展开后调用 `stopScroll()` 会脱离自动滚动,需要用户手动点滚动按钮回到底部 |
| 119 | +- 上下文归组只基于工具名匹配,不区分 MCP vs 内置工具 |
| 120 | +- Bash renderDetail 在历史消息中总是展开(showDetail 条件含 tool.result),长输出占空间 |
0 commit comments