fix: Trace Viewer 内容渲染修复
1. useAgentStream: 每轮迭代只创建一个 TraceStep, 状态转换时更新而非新建; tool_result 反向搜索包含 toolCallId 的步骤, 修复工具结果丢失 2. TraceStep: 渲染工具状态图标/结果/错误/耗时; Thought 添加 maxHeight 200+滚动; 展开状态用户操作后不被自动覆盖 3. ToolResultBlock: 移除 500 字符截断, 改为 maxHeight 300+滚动 4. ToolCallCard: 参数区域添加 overflowY auto+wordBreak
This commit is contained in:
@@ -43,7 +43,7 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{Object.keys(toolCall.args).length > 0 && (
|
{Object.keys(toolCall.args).length > 0 && (
|
||||||
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 80, m: 0 }}>
|
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', overflowY: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 80, m: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
{JSON.stringify(toolCall.args, null, 2)}
|
{JSON.stringify(toolCall.args, null, 2)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E
|
|||||||
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>{toolCall.name} 结果</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>{toolCall.name} 结果</Typography>
|
||||||
{toolCall.durationMs != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>}
|
{toolCall.durationMs != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 120, m: 0 }}>
|
<Box component="pre" sx={{ fontSize: 11, borderRadius: 1, px: 1, py: 0.75, overflowX: 'auto', overflowY: 'auto', bgcolor: 'background.default', color: 'text.secondary', fontFamily: "'SF Mono',monospace", maxHeight: 300, m: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
{resultStr.length > 500 ? resultStr.slice(0, 500) + '\n... [截断]' : resultStr}
|
{resultStr}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,72 @@
|
|||||||
/**
|
/**
|
||||||
* TraceStep — 单个 Trace 步骤
|
* 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 { 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 { TRACE_STATE_COLORS, TRACE_STATE_LABELS } from '@renderer/lib/constants';
|
||||||
import { formatDuration, formatTokens } from '@renderer/lib/formatters';
|
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';
|
import type { TraceStep as TraceStepType } from '@renderer/stores/agent-store';
|
||||||
|
|
||||||
interface TraceStepProps { step: TraceStepType; isCurrent?: boolean; }
|
interface TraceStepProps { step: TraceStepType; isCurrent?: boolean; }
|
||||||
|
|
||||||
|
// 工具状态图标
|
||||||
|
const TOOL_STATUS_ICONS: Record<string, typeof CheckCircle> = {
|
||||||
|
pending: Clock,
|
||||||
|
executing: Loader2,
|
||||||
|
success: CheckCircle,
|
||||||
|
error: XCircle,
|
||||||
|
blocked: Ban,
|
||||||
|
};
|
||||||
|
const TOOL_STATUS_COLORS: Record<string, string> = {
|
||||||
|
pending: '#fbbf24',
|
||||||
|
executing: '#a855f7',
|
||||||
|
success: '#34d399',
|
||||||
|
error: '#f87171',
|
||||||
|
blocked: '#fb923c',
|
||||||
|
};
|
||||||
|
const TOOL_STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: '等待',
|
||||||
|
executing: '执行中',
|
||||||
|
success: '成功',
|
||||||
|
error: '失败',
|
||||||
|
blocked: '阻止',
|
||||||
|
};
|
||||||
|
|
||||||
export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Element {
|
export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Element {
|
||||||
const [expanded, setExpanded] = useState(false);
|
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 color = TRACE_STATE_COLORS[step.state] ?? '#8b8fa7';
|
||||||
const label = TRACE_STATE_LABELS[step.state] ?? step.state;
|
const label = TRACE_STATE_LABELS[step.state] ?? step.state;
|
||||||
const duration = step.completedAt ? step.completedAt - step.startedAt : null;
|
const duration = step.completedAt ? step.completedAt - step.startedAt : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider', bgcolor: 'secondary.main', transition: 'all 150ms', ...(isCurrent ? { boxShadow: `0 0 0 1px ${color}` } : {}) }}>
|
<Box sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider', bgcolor: 'secondary.main', transition: 'all 150ms', ...(isCurrent ? { boxShadow: `0 0 0 1px ${color}` } : {}) }}>
|
||||||
<IconButton size="small" onClick={() => 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 }}>
|
<IconButton size="small" onClick={handleToggle} sx={{ width: '100%', justifyContent: 'flex-start', gap: 1, px: 1.5, py: 1, borderRadius: '4px 4px 0 0', color: 'text.primary', fontSize: 12 }}>
|
||||||
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
||||||
{isCurrent ? <Loader2 size={12} style={{ color, animation: 'spin 1s linear infinite' }} /> : step.completedAt ? <CheckCircle size={12} style={{ color }} /> : <CircleDot size={12} style={{ color }} />}
|
{isCurrent ? <Loader2 size={12} style={{ color, animation: 'spin 1s linear infinite' }} /> : step.completedAt ? <CheckCircle size={12} style={{ color }} /> : <CircleDot size={12} style={{ color }} />}
|
||||||
<Typography component="span" sx={{ fontWeight: 600, color, fontSize: 12 }}>#{step.iteration}</Typography>
|
<Typography component="span" sx={{ fontWeight: 600, color, fontSize: 12 }}>#{step.iteration}</Typography>
|
||||||
@@ -30,20 +76,88 @@ export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Elemen
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
<Collapse in={expanded}>
|
<Collapse in={expanded}>
|
||||||
<Box sx={{ px: 1.5, pb: 1.5, borderTop: 1, borderColor: 'divider' }}>
|
<Box sx={{ px: 1.5, pb: 1.5, borderTop: 1, borderColor: 'divider' }}>
|
||||||
|
{/* Thought 区域 */}
|
||||||
{step.thought && (
|
{step.thought && (
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 1 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'warning.main', fontSize: 10 }}>💭 Thought</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'warning.main', fontSize: 10 }}>💭 Thought</Typography>
|
||||||
<Box component="pre" sx={{ fontSize: 11, whiteSpace: 'pre-wrap', color: 'text.secondary', fontFamily: "'SF Mono',monospace", m: 0 }}>{step.thought}</Box>
|
<Box component="pre" sx={{
|
||||||
|
fontSize: 11, whiteSpace: 'pre-wrap', color: 'text.secondary',
|
||||||
|
fontFamily: "'SF Mono',monospace", m: 0, mt: 0.5,
|
||||||
|
maxHeight: 200, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{step.thought}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{step.toolCalls?.map((tc) => (
|
|
||||||
<Box key={tc.id} sx={{ mt: 1 }}>
|
{/* 工具调用区域 */}
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#a855f7', fontSize: 10 }}>🔧 {tc.name}</Typography>
|
{step.toolCalls && step.toolCalls.length > 0 && (
|
||||||
<Box component="pre" sx={{ fontSize: 10, overflowX: 'auto', color: 'text.secondary', fontFamily: "'SF Mono',monospace", m: 0 }}>{JSON.stringify(tc.args, null, 2)}</Box>
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#a855f7', fontSize: 10 }}>🔧 工具调用</Typography>
|
||||||
|
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
|
||||||
|
{step.toolCalls.map((tc) => (
|
||||||
|
<ToolCallRow key={tc.id} tc={tc} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 工具调用行 — 显示名称、状态、参数、结果摘要、耗时 */
|
||||||
|
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 (
|
||||||
|
<Box sx={{ borderRadius: 0.5, border: '1px solid', borderColor: 'divider', borderLeft: `2px solid ${statusColor}`, bgcolor: 'background.default', px: 1, py: 0.5 }}>
|
||||||
|
{/* 标题行 */}
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<StatusIcon size={10} style={{ color: statusColor, animation: tc.status === 'executing' ? 'spin 1s linear infinite' : 'none' }} />
|
||||||
|
<Typography component="span" sx={{ fontSize: 10, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}>{tc.name}</Typography>
|
||||||
|
<Typography component="span" sx={{ fontSize: 9, color: statusColor }}>{statusLabel}</Typography>
|
||||||
|
{tc.durationMs != null && (
|
||||||
|
<Typography component="span" sx={{ fontSize: 9, color: 'text.secondary', ml: 'auto' }}>{formatDuration(tc.durationMs)}</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 参数 */}
|
||||||
|
{Object.keys(tc.args).length > 0 && (
|
||||||
|
<Box component="pre" sx={{
|
||||||
|
fontSize: 9, color: 'text.secondary', fontFamily: "'SF Mono',monospace",
|
||||||
|
m: 0, mt: 0.25, maxHeight: 60, overflowY: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(tc.args, null, 2)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{tc.status === 'error' && tc.error && (
|
||||||
|
<Typography sx={{ fontSize: 9, color: 'error.main', mt: 0.25, wordBreak: 'break-word' }}>{tc.error}</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 结果摘要 */}
|
||||||
|
{hasResult && (
|
||||||
|
<Box component="pre" sx={{
|
||||||
|
fontSize: 9, color: 'text.secondary', fontFamily: "'SF Mono',monospace",
|
||||||
|
m: 0, mt: 0.25, maxHeight: 80, overflowY: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||||
|
borderTop: '1px dashed', borderColor: 'divider', pt: 0.25,
|
||||||
|
}}>
|
||||||
|
{resultStr}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+41
-31
@@ -4,10 +4,10 @@
|
|||||||
* 监听 window.metona.agent.onStreamEvent / onStateChange,将事件分发到 Zustand Store。
|
* 监听 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)
|
* - onStreamEvent:纯内容更新(reasoning、text、tool_call、tool_result、usage、done、error)
|
||||||
* (不再处理迭代号比较,消除竞态条件)
|
* tool_result 反向搜索包含对应 toolCallId 的 TraceStep(修复工具结果丢失)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
@@ -147,22 +147,27 @@ export function useAgentStream(): void {
|
|||||||
getStore().updateMessage(lastMsg.id, { toolCalls: updatedToolCalls });
|
getStore().updateMessage(lastMsg.id, { toolCalls: updatedToolCalls });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步更新 Trace 步骤中的工具调用状态
|
// 反向搜索包含该 toolCallId 的 TraceStep(修复工具结果丢失)
|
||||||
|
// tool_call_complete 在 THINKING 阶段写入,tool_result 在 EXECUTING 阶段到达
|
||||||
|
// 两者可能在同一轮迭代但不同状态转换,需反向查找
|
||||||
const steps = getStore().traceSteps;
|
const steps = getStore().traceSteps;
|
||||||
const lastTrace = steps[steps.length - 1];
|
for (let i = steps.length - 1; i >= 0; i--) {
|
||||||
if (lastTrace?.toolCalls) {
|
const trace = steps[i];
|
||||||
const updatedTraceToolCalls = lastTrace.toolCalls.map((tc) =>
|
if (trace.toolCalls?.some((tc) => tc.id === data.toolResult!.toolCallId)) {
|
||||||
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,
|
...tc,
|
||||||
result: data.toolResult!.result,
|
status: data.toolResult!.success ? 'success' as const : 'error' as const,
|
||||||
error: data.toolResult!.error,
|
result: data.toolResult!.result,
|
||||||
durationMs: data.toolResult!.durationMs,
|
error: data.toolResult!.error,
|
||||||
}
|
durationMs: data.toolResult!.durationMs,
|
||||||
: tc,
|
}
|
||||||
);
|
: tc,
|
||||||
getStore().updateLastTraceStep({ toolCalls: updatedTraceToolCalls });
|
);
|
||||||
|
getStore().updateTraceStep(trace.iteration, { toolCalls: updatedTraceToolCalls });
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -260,26 +265,31 @@ export function useAgentStream(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Trace 步骤管理 ---
|
// --- Trace 步骤管理(每轮迭代一个步骤,状态转换时更新而非新建)---
|
||||||
if (data.state && data.iteration != null) {
|
if (data.state && data.iteration != null) {
|
||||||
const traceSteps = store.traceSteps;
|
const traceSteps = store.traceSteps;
|
||||||
|
const lastStep = traceSteps[traceSteps.length - 1];
|
||||||
|
|
||||||
// 标记上一个 Trace 步骤为已完成
|
// 判断是否需要创建新步骤:同一迭代的首次状态(INIT/THINKING)创建新步骤
|
||||||
if (traceSteps.length > 0) {
|
// 后续状态转换(EXECUTING/OBSERVING等)只更新当前步骤的 state 字段
|
||||||
const lastStep = traceSteps[traceSteps.length - 1];
|
const isSameIteration = lastStep && lastStep.iteration === data.iteration;
|
||||||
if (!lastStep.completedAt) {
|
|
||||||
|
if (isSameIteration) {
|
||||||
|
// 同一迭代内的状态转换 → 更新当前步骤状态
|
||||||
|
store.updateLastTraceStep({ state: data.state });
|
||||||
|
} else {
|
||||||
|
// 新迭代 → 标记上一步完成,创建新步骤
|
||||||
|
if (lastStep && !lastStep.completedAt) {
|
||||||
store.updateLastTraceStep({ completedAt: Date.now() });
|
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 状态映射 ---
|
// --- Agent 状态映射 ---
|
||||||
const stateToStatus: Record<string, AgentStatus> = {
|
const stateToStatus: Record<string, AgentStatus> = {
|
||||||
THINKING: 'thinking',
|
THINKING: 'thinking',
|
||||||
|
|||||||
Reference in New Issue
Block a user