From ed59305b5e1599178ee1750ecf6fc016d8f202f1 Mon Sep 17 00:00:00 2001 From: thzxx Date: Tue, 7 Jul 2026 21:13:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Trace=20Viewer=20=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. useAgentStream: 每轮迭代只创建一个 TraceStep, 状态转换时更新而非新建; tool_result 反向搜索包含 toolCallId 的步骤, 修复工具结果丢失 2. TraceStep: 渲染工具状态图标/结果/错误/耗时; Thought 添加 maxHeight 200+滚动; 展开状态用户操作后不被自动覆盖 3. ToolResultBlock: 移除 500 字符截断, 改为 maxHeight 300+滚动 4. ToolCallCard: 参数区域添加 overflowY auto+wordBreak --- src/components/chat/ToolCallCard.tsx | 2 +- src/components/chat/ToolResultBlock.tsx | 4 +- src/components/trace/TraceStep.tsx | 134 ++++++++++++++++++++++-- src/hooks/useAgentStream.ts | 72 +++++++------ 4 files changed, 168 insertions(+), 44 deletions(-) diff --git a/src/components/chat/ToolCallCard.tsx b/src/components/chat/ToolCallCard.tsx index 2727357..34765a8 100644 --- a/src/components/chat/ToolCallCard.tsx +++ b/src/components/chat/ToolCallCard.tsx @@ -43,7 +43,7 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element {Object.keys(toolCall.args).length > 0 && ( - + {JSON.stringify(toolCall.args, null, 2)} )} diff --git a/src/components/chat/ToolResultBlock.tsx b/src/components/chat/ToolResultBlock.tsx index 7662007..d7bf321 100644 --- a/src/components/chat/ToolResultBlock.tsx +++ b/src/components/chat/ToolResultBlock.tsx @@ -20,8 +20,8 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E {toolCall.name} 结果 {toolCall.durationMs != null && {formatDuration(toolCall.durationMs)}} - - {resultStr.length > 500 ? resultStr.slice(0, 500) + '\n... [截断]' : resultStr} + + {resultStr} ); diff --git a/src/components/trace/TraceStep.tsx b/src/components/trace/TraceStep.tsx index 8fd3f76..8a6f686 100644 --- a/src/components/trace/TraceStep.tsx +++ b/src/components/trace/TraceStep.tsx @@ -1,26 +1,72 @@ /** * TraceStep — 单个 Trace 步骤 + * + * 渲染一轮迭代的完整生命周期:Thought + ToolCalls(含状态/结果/耗时) */ -import { useState, useEffect } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Box, Typography, IconButton, Collapse, Stack } from '@mui/material'; -import { ChevronDown, ChevronRight, CircleDot, CheckCircle, Loader2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, CircleDot, CheckCircle, Loader2, Clock, XCircle, Ban } from 'lucide-react'; import { TRACE_STATE_COLORS, TRACE_STATE_LABELS } from '@renderer/lib/constants'; import { formatDuration, formatTokens } from '@renderer/lib/formatters'; +import type { ToolCallInfo } from '@renderer/stores/agent-store'; import type { TraceStep as TraceStepType } from '@renderer/stores/agent-store'; interface TraceStepProps { step: TraceStepType; isCurrent?: boolean; } +// 工具状态图标 +const TOOL_STATUS_ICONS: Record = { + pending: Clock, + executing: Loader2, + success: CheckCircle, + error: XCircle, + blocked: Ban, +}; +const TOOL_STATUS_COLORS: Record = { + pending: '#fbbf24', + executing: '#a855f7', + success: '#34d399', + error: '#f87171', + blocked: '#fb923c', +}; +const TOOL_STATUS_LABELS: Record = { + pending: '等待', + executing: '执行中', + success: '成功', + error: '失败', + blocked: '阻止', +}; + export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Element { const [expanded, setExpanded] = useState(false); - useEffect(() => { setExpanded(isCurrent ?? false); }, [isCurrent]); + const [userTouched, setUserTouched] = useState(false); + const prevIsCurrentRef = useRef(isCurrent); + + // 仅在 isCurrent 从 false→true 变化时自动展开(新步骤到达) + // 用户手动操作后不再被自动行为覆盖 + useEffect(() => { + const wasCurrent = prevIsCurrentRef.current; + prevIsCurrentRef.current = isCurrent; + + if (isCurrent && !wasCurrent && !userTouched) { + setExpanded(true); + } else if (!isCurrent && wasCurrent && !userTouched) { + setExpanded(false); + } + }, [isCurrent, userTouched]); + + const handleToggle = () => { + setUserTouched(true); + setExpanded(!expanded); + }; + const color = TRACE_STATE_COLORS[step.state] ?? '#8b8fa7'; const label = TRACE_STATE_LABELS[step.state] ?? step.state; const duration = step.completedAt ? step.completedAt - step.startedAt : null; return ( - setExpanded(!expanded)} sx={{ width: '100%', justifyContent: 'flex-start', gap: 1, px: 1.5, py: 1, borderRadius: '4px 4px 0 0', color: 'text.primary', fontSize: 12 }}> + {expanded ? : } {isCurrent ? : step.completedAt ? : } #{step.iteration} @@ -30,20 +76,88 @@ export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Elemen + {/* Thought 区域 */} {step.thought && ( 💭 Thought - {step.thought} + + {step.thought} + )} - {step.toolCalls?.map((tc) => ( - - 🔧 {tc.name} - {JSON.stringify(tc.args, null, 2)} + + {/* 工具调用区域 */} + {step.toolCalls && step.toolCalls.length > 0 && ( + + 🔧 工具调用 + + {step.toolCalls.map((tc) => ( + + ))} + - ))} + )} ); } + +/** 工具调用行 — 显示名称、状态、参数、结果摘要、耗时 */ +function ToolCallRow({ tc }: { tc: ToolCallInfo }): React.JSX.Element { + const StatusIcon = TOOL_STATUS_ICONS[tc.status] ?? Clock; + const statusColor = TOOL_STATUS_COLORS[tc.status] ?? '#8b8fa7'; + const statusLabel = TOOL_STATUS_LABELS[tc.status] ?? tc.status; + + // 结果摘要(不截断,限制高度 + 滚动) + const hasResult = tc.result != null; + const resultStr = hasResult + ? (typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result, null, 2)) + : ''; + + return ( + + {/* 标题行 */} + + + {tc.name} + {statusLabel} + {tc.durationMs != null && ( + {formatDuration(tc.durationMs)} + )} + + + {/* 参数 */} + {Object.keys(tc.args).length > 0 && ( + + {JSON.stringify(tc.args, null, 2)} + + )} + + {/* 错误信息 */} + {tc.status === 'error' && tc.error && ( + {tc.error} + )} + + {/* 结果摘要 */} + {hasResult && ( + + {resultStr} + + )} + + ); +} diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index bfb5ad5..21fc66a 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -4,10 +4,10 @@ * 监听 window.metona.agent.onStreamEvent / onStateChange,将事件分发到 Zustand Store。 * * 架构设计: - * - onStateChange:迭代号追踪 + 消息卡片创建 + Trace 步骤创建 + Agent 状态映射 - * (状态变化事件总是先于同迭代的流式内容事件到达,因此在此统一管理迭代边界) + * - onStateChange:迭代号追踪 + 消息卡片创建 + Trace 步骤管理 + Agent 状态映射 + * 每轮迭代只创建一个 TraceStep,状态转换时更新当前步骤的 state 字段(避免碎片化) * - onStreamEvent:纯内容更新(reasoning、text、tool_call、tool_result、usage、done、error) - * (不再处理迭代号比较,消除竞态条件) + * tool_result 反向搜索包含对应 toolCallId 的 TraceStep(修复工具结果丢失) */ import { useEffect, useRef } from 'react'; @@ -147,22 +147,27 @@ export function useAgentStream(): void { getStore().updateMessage(lastMsg.id, { toolCalls: updatedToolCalls }); } - // 同步更新 Trace 步骤中的工具调用状态 + // 反向搜索包含该 toolCallId 的 TraceStep(修复工具结果丢失) + // tool_call_complete 在 THINKING 阶段写入,tool_result 在 EXECUTING 阶段到达 + // 两者可能在同一轮迭代但不同状态转换,需反向查找 const steps = getStore().traceSteps; - const lastTrace = steps[steps.length - 1]; - if (lastTrace?.toolCalls) { - const updatedTraceToolCalls = lastTrace.toolCalls.map((tc) => - tc.id === data.toolResult!.toolCallId - ? { - ...tc, - status: data.toolResult!.success ? 'success' as const : 'error' as const, - result: data.toolResult!.result, - error: data.toolResult!.error, - durationMs: data.toolResult!.durationMs, - } - : tc, - ); - getStore().updateLastTraceStep({ toolCalls: updatedTraceToolCalls }); + for (let i = steps.length - 1; i >= 0; i--) { + const trace = steps[i]; + if (trace.toolCalls?.some((tc) => tc.id === data.toolResult!.toolCallId)) { + const updatedTraceToolCalls = trace.toolCalls.map((tc) => + tc.id === data.toolResult!.toolCallId + ? { + ...tc, + status: data.toolResult!.success ? 'success' as const : 'error' as const, + result: data.toolResult!.result, + error: data.toolResult!.error, + durationMs: data.toolResult!.durationMs, + } + : tc, + ); + getStore().updateTraceStep(trace.iteration, { toolCalls: updatedTraceToolCalls }); + break; + } } } break; @@ -260,26 +265,31 @@ export function useAgentStream(): void { } } - // --- Trace 步骤管理 --- + // --- Trace 步骤管理(每轮迭代一个步骤,状态转换时更新而非新建)--- if (data.state && data.iteration != null) { const traceSteps = store.traceSteps; + const lastStep = traceSteps[traceSteps.length - 1]; - // 标记上一个 Trace 步骤为已完成 - if (traceSteps.length > 0) { - const lastStep = traceSteps[traceSteps.length - 1]; - if (!lastStep.completedAt) { + // 判断是否需要创建新步骤:同一迭代的首次状态(INIT/THINKING)创建新步骤 + // 后续状态转换(EXECUTING/OBSERVING等)只更新当前步骤的 state 字段 + const isSameIteration = lastStep && lastStep.iteration === data.iteration; + + if (isSameIteration) { + // 同一迭代内的状态转换 → 更新当前步骤状态 + store.updateLastTraceStep({ state: data.state }); + } else { + // 新迭代 → 标记上一步完成,创建新步骤 + if (lastStep && !lastStep.completedAt) { store.updateLastTraceStep({ completedAt: Date.now() }); } + store.addTraceStep({ + id: `trace_${data.iteration}_${data.state}_${Date.now()}`, + iteration: data.iteration, + state: data.state, + startedAt: Date.now(), + }); } - // 创建新的 Trace 步骤 - store.addTraceStep({ - id: `trace_${data.iteration}_${data.state}_${Date.now()}`, - iteration: data.iteration, - state: data.state, - startedAt: Date.now(), - }); - // --- Agent 状态映射 --- const stateToStatus: Record = { THINKING: 'thinking',