diff --git a/src/components/trace/TraceViewer.tsx b/src/components/trace/TraceViewer.tsx index 72604ac..f0ff1ce 100644 --- a/src/components/trace/TraceViewer.tsx +++ b/src/components/trace/TraceViewer.tsx @@ -1,17 +1,70 @@ /** * TraceViewer — ReAct 迭代追踪时间轴 * + * 方案 A+: 按 runId 分组渲染,每轮 AI 回复一个独立 section + * - 所有历史 run 的 steps 都可见 + * - 每个 run 视觉分离(带标题"第 N 轮"+ 状态徽标) + * - 当前 run 高亮显示 + * * flex:1 撑满详情面板剩余空间,内容超出时内部滚动。 */ -import { Box, Typography, Stack } from '@mui/material'; -import { Activity } from 'lucide-react'; +import { Box, Typography, Stack, Chip } from '@mui/material'; +import { Activity, MessageSquare } from 'lucide-react'; +import { useMemo } from 'react'; import { useAgentStore } from '@renderer/stores/agent-store'; +import type { TraceStep as TraceStepType } from '@renderer/stores/agent-store'; import { TraceStep } from './TraceStep'; +interface RunGroup { + runId: string; + index: number; // 1-based 轮次序号 + steps: TraceStepType[]; + isCurrent: boolean; // 是否为当前正在进行的 run + isCompleted: boolean; // 是否已完成(最后一个 step 有 completedAt) +} + +function groupByRun(traceSteps: TraceStepType[], currentRunId: string | null): RunGroup[] { + const runOrder: string[] = []; + const runMap = new Map(); + + for (const step of traceSteps) { + const rid = step.runId; + if (!rid) continue; + if (!runMap.has(rid)) { + runMap.set(rid, []); + runOrder.push(rid); + } + runMap.get(rid)!.push(step); + } + + // 当前 runId 优先;若为空(idle)则取最后一个 run 作为"当前展示" + const effectiveCurrentRunId = currentRunId ?? (runOrder.length > 0 ? runOrder[runOrder.length - 1] : null); + + return runOrder.map((rid, i) => { + const steps = runMap.get(rid)!; + const lastStep = steps[steps.length - 1]; + return { + runId: rid, + index: i + 1, + steps, + isCurrent: rid === effectiveCurrentRunId, + isCompleted: Boolean(lastStep?.completedAt), + }; + }); +} + export function TraceViewer(): React.JSX.Element { - const traceSteps = useAgentStore((s) => s.traceSteps); + const allTraceSteps = useAgentStore((s) => s.traceSteps); const agentStatus = useAgentStore((s) => s.agentStatus); + const currentRunId = useAgentStore((s) => s.currentRunId); + + const groups = useMemo( + () => groupByRun(allTraceSteps, currentRunId), + [allTraceSteps, currentRunId], + ); + + const isEmpty = groups.length === 0; return ( @@ -20,20 +73,79 @@ export function TraceViewer(): React.JSX.Element { Trace Viewer + {!isEmpty && ( + + 共 {groups.length} 轮 + + )} - - {traceSteps.length === 0 ? ( + + {isEmpty ? ( 等待 Agent 活动... ) : ( - traceSteps.map((step, i) => ( - + groups.map((group) => ( + + + + + 第 {group.index} 轮 + + {group.isCurrent && agentStatus !== 'idle' && ( + + )} + {group.isCompleted && !group.isCurrent && ( + + )} + + {group.steps.length} 步 + + + + + {group.steps.map((step, i) => ( + + ))} + + )) )} diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index 5fb6719..a1ed7bc 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -267,9 +267,9 @@ export const useAgentStore = create((set, get) => ({ isStreaming: true, currentRunId: null, // 将在首个 streamEvent 中由 runId 设置 currentIteration: 0, - // 重置 trace 状态:防止新消息在上一条消息的 trace 上面叠加渲染 - // 前一条消息的 trace 已在 done 事件中通过 saveTraceData() 持久化到 DB - traceSteps: [], + // 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖 + // TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps + // 历史 trace 仍在 DB 中,切换会话回来可恢复 tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, }));