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:
@@ -1,17 +1,70 @@
|
|||||||
/**
|
/**
|
||||||
* TraceViewer — ReAct 迭代追踪时间轴
|
* TraceViewer — ReAct 迭代追踪时间轴
|
||||||
*
|
*
|
||||||
|
* 方案 A+: 按 runId 分组渲染,每轮 AI 回复一个独立 section
|
||||||
|
* - 所有历史 run 的 steps 都可见
|
||||||
|
* - 每个 run 视觉分离(带标题"第 N 轮"+ 状态徽标)
|
||||||
|
* - 当前 run 高亮显示
|
||||||
|
*
|
||||||
* flex:1 撑满详情面板剩余空间,内容超出时内部滚动。
|
* flex:1 撑满详情面板剩余空间,内容超出时内部滚动。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, Typography, Stack } from '@mui/material';
|
import { Box, Typography, Stack, Chip } from '@mui/material';
|
||||||
import { Activity } from 'lucide-react';
|
import { Activity, MessageSquare } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||||
|
import type { TraceStep as TraceStepType } from '@renderer/stores/agent-store';
|
||||||
import { TraceStep } from './TraceStep';
|
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 {
|
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 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 (
|
return (
|
||||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
<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' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||||
Trace Viewer
|
Trace Viewer
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{!isEmpty && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', ml: 'auto' }}>
|
||||||
|
共 {groups.length} 轮
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5, minHeight: 0 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 1.5, minHeight: 0 }}>
|
||||||
{traceSteps.length === 0 ? (
|
{isEmpty ? (
|
||||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
等待 Agent 活动...
|
等待 Agent 活动...
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
traceSteps.map((step, i) => (
|
groups.map((group) => (
|
||||||
<TraceStep
|
<Box
|
||||||
key={step.id}
|
key={group.runId}
|
||||||
step={step}
|
sx={{
|
||||||
isCurrent={i === traceSteps.length - 1 && agentStatus !== 'idle'}
|
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={group.isCurrent && i === group.steps.length - 1 && agentStatus !== 'idle'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -267,9 +267,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
currentRunId: null, // 将在首个 streamEvent 中由 runId 设置
|
currentRunId: null, // 将在首个 streamEvent 中由 runId 设置
|
||||||
currentIteration: 0,
|
currentIteration: 0,
|
||||||
// 重置 trace 状态:防止新消息在上一条消息的 trace 上面叠加渲染
|
// 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖
|
||||||
// 前一条消息的 trace 已在 done 事件中通过 saveTraceData() 持久化到 DB
|
// TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps
|
||||||
traceSteps: [],
|
// 历史 trace 仍在 DB 中,切换会话回来可恢复
|
||||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user