fix(trace): Trace Viewer 改为按 runId 分组渲染,显示所有轮次

修复 v0.3.8 引入的"覆盖"问题:sendMessage 清空 traceSteps 导致
前一条消息的 trace 被永久丢失。

方案 A+ — 既不叠加也不覆盖,按 runId 分组展示所有轮次:
- agent-store.ts: 回退 sendMessage 的 traceSteps: [] 清空(保留历史)
  保留 tokenUsage 重置(当前 run 统计应该重置)
- TraceViewer.tsx: 重写渲染逻辑
  - 新增 groupByRun 工具函数,按 runId 分组 + 计算轮次序号
  - 每个 run 独立 section,带"第 N 轮"标题 + 状态徽标(进行中/已完成)+ 步数
  - 当前 run 高亮(primary 边框 + 浅色背景 + MessageSquare 图标)
  - 历史 run 弱化(divider 边框 + 透明背景 + 0.85 透明度)
  - 顶部统计"共 N 轮"
  - isCurrent 判定改为"当前 run 的最后一个 step"(之前是"全局最后一个")

useAgentStream 的 runId 隔离逻辑(v0.3.8 已加)无需改动,
正好支撑多 run 共存设计:isSameIteration 校验 runId,跨 run 孤立
TERMINATED 跳过不创建 step。

冒烟测试场景:
- 多轮对话 → 每轮独立显示,不再叠加
- 历史 trace 不丢失 → 切换会话回来仍可见所有轮次
- 当前 run 高亮 → 视觉聚焦"正在进行的活动"
This commit is contained in:
2026-07-20 21:57:03 +08:00
parent dde21f0b3f
commit 5ec32f33da
2 changed files with 126 additions and 14 deletions
+119 -7
View File
@@ -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<string, TraceStepType[]>();
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 (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
@@ -20,20 +73,79 @@ export function TraceViewer(): React.JSX.Element {
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
Trace Viewer
</Typography>
{!isEmpty && (
<Typography variant="caption" sx={{ color: 'text.disabled', ml: 'auto' }}>
{groups.length}
</Typography>
)}
</Stack>
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5, minHeight: 0 }}>
{traceSteps.length === 0 ? (
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 1.5, minHeight: 0 }}>
{isEmpty ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
Agent ...
</Typography>
) : (
traceSteps.map((step, i) => (
groups.map((group) => (
<Box
key={group.runId}
sx={{
border: 1,
borderColor: group.isCurrent ? 'primary.main' : 'divider',
borderRadius: 1,
p: 1,
bgcolor: group.isCurrent ? 'action.hover' : 'transparent',
opacity: group.isCurrent ? 1 : 0.85,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ mb: 1, alignItems: 'center', flexShrink: 0 }}
>
<MessageSquare size={12} style={{ color: group.isCurrent ? '#818cf8' : '#9ca3af' }} />
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: group.isCurrent ? 'primary.main' : 'text.secondary',
letterSpacing: 0.5,
}}
>
{group.index}
</Typography>
{group.isCurrent && agentStatus !== 'idle' && (
<Chip
label="进行中"
size="small"
color="primary"
variant="outlined"
sx={{ height: 16, fontSize: 10, '& .MuiChip-label': { px: 0.5 } }}
/>
)}
{group.isCompleted && !group.isCurrent && (
<Chip
label="已完成"
size="small"
variant="outlined"
sx={{ height: 16, fontSize: 10, '& .MuiChip-label': { px: 0.5 }, color: 'text.disabled' }}
/>
)}
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{group.steps.length}
</Typography>
</Stack>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{group.steps.map((step, i) => (
<TraceStep
key={step.id}
step={step}
isCurrent={i === traceSteps.length - 1 && agentStatus !== 'idle'}
isCurrent={group.isCurrent && i === group.steps.length - 1 && agentStatus !== 'idle'}
/>
))}
</Box>
</Box>
))
)}
</Box>
+3 -3
View File
@@ -267,9 +267,9 @@ export const useAgentStore = create<AgentState>((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 },
}));