feat: unify debug runtime timelines

This commit is contained in:
Xin Wang
2026-08-13 09:30:43 +08:00
parent 7c18cea16d
commit eb111a3890
21 changed files with 1380 additions and 533 deletions
@@ -22,6 +22,10 @@ import {
X,
} from "lucide-react";
import {
DebugTimeline,
type DebugTimelineItem,
} from "@/components/debug-timeline";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -252,17 +256,32 @@ function CasePicker({
export function AutoTestLivePanel({
testCase,
state,
onStart,
}: {
testCase: TestCase | null;
state: AutoTestRunState;
onStart: () => void;
}) {
if (!testCase) {
return (
<EmptyPanel
icon={<FlaskConical size={22} />}
title="选择一个测试用例"
description="运行真实 Pipeline,并在这里查看逐轮回复、工具调用和判断结果。"
/>
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 py-8 text-center">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-muted-soft">
<FlaskConical size={19} />
</span>
<div className="mt-3 text-sm font-medium text-foreground">
选择测试用例后开始
</div>
<Button
type="button"
variant="secondary"
className="mt-4 h-10 gap-2 rounded-full px-5"
disabled
title="请先在下方选择测试用例"
>
<Play size={14} />
开始测试
</Button>
</div>
);
}
@@ -270,20 +289,20 @@ export function AutoTestLivePanel({
const validationMessage = runnableMessage(testCase);
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-6 py-8 text-center">
<div className="max-w-sm">
<div className="text-base font-medium text-foreground">
<div className="w-full max-w-sm">
<div className="text-lg font-medium tracking-[-0.01em] text-foreground">
{testCase.name}
</div>
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
<Badge
variant="secondary"
className="rounded-full px-2 py-0 text-[10px] font-medium"
>
{TEST_CASE_INPUT_MODE_SHORT_LABEL[testCase.inputMode]}
</Badge>
<span className="text-[11px] text-muted-soft">
{testCase.turns.length} 轮
</span>
<div className="mt-2 flex flex-wrap items-center justify-center gap-2 text-xs text-muted-foreground">
<span>{TEST_CASE_INPUT_MODE_SHORT_LABEL[testCase.inputMode]}</span>
<span aria-hidden className="text-hairline-strong">·</span>
<span>{testCase.turns.length} 轮</span>
{state.evaluatorName && (
<>
<span aria-hidden className="text-hairline-strong">·</span>
<span>{state.evaluatorName}</span>
</>
)}
</div>
{testCase.description && (
<p className="mt-3 text-xs leading-5 text-muted-foreground">
@@ -294,11 +313,22 @@ export function AutoTestLivePanel({
<div className="mt-4 rounded-xl border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-xs leading-5 text-amber-600 dark:text-amber-400">
{validationMessage}
</div>
) : state.evaluatorName ? (
<p className="mt-4 text-[11px] text-muted-soft">
评估模型 · {state.evaluatorName}
</p>
) : null}
<Button
type="button"
className="mx-auto mt-5 h-10 gap-2 rounded-full px-5"
disabled={Boolean(state.blockedMessage)}
onClick={onStart}
title={state.blockedMessage || "开始测试"}
>
<Play size={14} />
开始测试
</Button>
{state.blockedMessage && !validationMessage && (
<p className="mt-2 text-[10px] leading-4 text-muted-soft">
{state.blockedMessage}
</p>
)}
</div>
</div>
);
@@ -327,8 +357,11 @@ export function AutoTestLivePanel({
if (startup?.completed) {
return (
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto px-5 py-4">
<StartupDetails startup={startup} />
<StartupAssistantMessages startup={startup} />
<AutoTestTimeline
startup={startup}
turns={[]}
maxTurns={state.maxTurns}
/>
<div className="flex items-center justify-center gap-2 py-4 text-xs text-muted-foreground">
<Loader2 size={13} className="animate-spin" />
正在执行第 1 轮固定输入…
@@ -351,62 +384,11 @@ export function AutoTestLivePanel({
return (
<div className="scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4">
<div className="flex flex-col gap-4">
{startup?.completed && <StartupDetails startup={startup} />}
{startup?.completed && <StartupAssistantMessages startup={startup} />}
{turns.map((turn) => (
<section key={turn.id} className="flex flex-col gap-3">
<div className="flex max-w-[88%] flex-col items-end gap-1 self-end">
<span className="flex items-center gap-2 px-1 text-[11px] text-muted-soft">
<span>固定输入</span>
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px]">
{turn.index + 1} / {state.maxTurns}
</span>
</span>
<div className="whitespace-pre-wrap rounded-2xl rounded-tr-sm bg-primary px-4 py-2.5 text-sm leading-6 text-primary-foreground">
{turn.userInput}
</div>
</div>
<div className="flex max-w-[88%] flex-col gap-1 self-start">
<span className="px-1 text-[11px] text-muted-soft">助手</span>
<div className="whitespace-pre-wrap rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
{turn.assistantReply || "(没有生成文字回复)"}
</div>
</div>
{turn.toolCalls.length > 0 && (
<div className="w-full space-y-2 rounded-xl border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-foreground">
<Wrench size={12} />
工具调用
</div>
{turn.toolCalls.map((toolCall) => (
<div key={toolCall.id} className="text-[11px] leading-5">
<code className="text-foreground">{toolCall.functionName}</code>
<span className="ml-2 text-muted-soft">
{toolCall.outcome === "success" ? "成功" : "错误"} ·{" "}
{toolCall.durationMs} ms
</span>
<pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-background/80 p-2 font-mono text-[10px] text-muted-foreground">
{toolCall.argumentsJson}
</pre>
</div>
))}
</div>
)}
{turn.evaluations.length > 0 && (
<div className="w-full space-y-2">
{turn.evaluations.map((evaluation) => (
<EvaluationDetails
key={evaluation.id}
evaluation={evaluation}
/>
))}
</div>
)}
</section>
))}
<AutoTestTimeline
startup={startup}
turns={turns}
maxTurns={state.maxTurns}
/>
{state.phase === "running" && (
<div className="flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground">
@@ -446,78 +428,15 @@ export function AutoTestLivePanel({
);
}
function StartupDetails({ startup }: { startup: BatchStartupResult }) {
function startupAssistantMessages(startup: BatchStartupResult): string[] {
const greeting = startup.greeting.trim();
return (
<section className="w-full space-y-3 rounded-xl border border-hairline bg-canvas-soft px-3 py-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="text-xs font-medium text-foreground">开场记录</div>
<p className="mt-0.5 text-[10px] leading-4 text-muted-soft">
已按真实开场流程模拟,不计入脚本轮次断言
</p>
</div>
<Badge
variant="secondary"
className="rounded-full px-2 py-0 text-[10px] font-medium"
>
已完成
</Badge>
</div>
{greeting && (
<StartupTextBlock label="开场白 · 按已播放处理" value={greeting} />
)}
{startup.autoConfirmedMessages.map((message, index) => {
const confirmedAction =
message.actions?.find((action) => action.id === "confirmed") ??
message.actions?.[0];
return (
<div
key={`${index}-${message.title ?? message.message ?? "message"}`}
className="rounded-xl border border-hairline bg-background/70 px-3 py-2.5"
>
<div className="text-[10px] font-medium text-muted-soft">
消息弹窗{startup.autoConfirmedMessages.length > 1 ? ` ${index + 1}` : ""}
</div>
<div className="mt-1 text-xs font-medium text-foreground">
{message.title?.trim() || "未命名消息"}
</div>
<p className="mt-1 whitespace-pre-wrap break-words text-[11px] leading-5 text-muted-foreground">
{message.message?.trim() || "(没有消息正文)"}
</p>
<div className="mt-2 inline-flex items-center gap-1 rounded-full border border-success/20 px-2 py-0.5 text-[10px] text-success">
<Check size={10} />
自动点击“{confirmedAction?.label?.trim() || "确认"}”
</div>
</div>
);
})}
</section>
);
}
function StartupAssistantMessages({
startup,
}: {
startup: BatchStartupResult;
}) {
const openingTexts = new Set(
[
startup.greeting,
...startup.autoConfirmedMessages.map((message) => message.message ?? ""),
]
.map(normalizeDisplayText)
.filter(Boolean),
);
const greetingText = normalizeDisplayText(greeting);
const seen = new Set<string>();
const assistantMessages = startup.assistantOutputs.filter((output) => {
const normalizedOutput = normalizeDisplayText(output);
if (
!normalizedOutput ||
openingTexts.has(normalizedOutput) ||
normalizedOutput === greetingText ||
seen.has(normalizedOutput)
) {
return false;
@@ -525,43 +444,235 @@ function StartupAssistantMessages({
seen.add(normalizedOutput);
return true;
});
return [greeting, ...assistantMessages].filter(Boolean);
}
if (assistantMessages.length === 0) return null;
function legacyStartupRuntimeRecords(
startup: BatchStartupResult,
): Array<Record<string, unknown>> {
return startup.autoConfirmedMessages.flatMap((message, index) => {
const confirmedAction =
message.actions?.find((action) => action.id === "confirmed") ??
message.actions?.[0];
const legacyId = `${index}-${message.title ?? message.message ?? "message"}`;
return [
{
eventId: `legacy-message-started-${legacyId}`,
event: "message_started",
phase: "opening",
requiresConfirmation: true,
messageTitle: message.title?.trim() || "未命名消息",
messageContent: message.message?.trim() || "(没有消息正文)",
messageActions: message.actions ?? [],
},
{
eventId: `legacy-message-completed-${legacyId}`,
event: "message_completed",
phase: "opening",
action: confirmedAction?.id?.trim() || "confirmed",
},
];
});
}
const label =
startup.autoConfirmedMessages.length > 0
? "助手 · 确认后自动输出"
: "助手 · 开场自动输出";
return (
<div className="flex w-full flex-col gap-3">
{assistantMessages.map((message, index) => (
<div
key={`${index}-${message}`}
className="flex max-w-[88%] flex-col gap-1 self-start"
>
<span className="px-1 text-[11px] text-muted-soft">{label}</span>
<div className="whitespace-pre-wrap break-words rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
{message.trim()}
</div>
</div>
))}
</div>
);
function runtimeRecordKey(
event: Record<string, unknown>,
prefix: string,
index: number,
): string {
const id = typeof event.eventId === "string" ? event.eventId : "";
const name = typeof event.event === "string" ? event.event : "record";
return id || `${prefix}-${name}-${index}`;
}
function normalizeDisplayText(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
function StartupTextBlock({ label, value }: { label: string; value: string }) {
function AutoTestTimeline({
startup,
turns,
maxTurns,
}: {
startup?: BatchStartupResult;
turns: BatchRunCase["turns"];
maxTurns: number;
}) {
const items: DebugTimelineItem[] = [];
if (startup?.completed) {
items.push({
id: "startup-boundary",
kind: "content",
content: (
<div className="flex items-center gap-2 py-1 text-[11px] text-muted-soft">
<span className="h-px flex-1 bg-hairline" />
<span>开场阶段 · 自动执行,不计入脚本断言</span>
<span className="h-px flex-1 bg-hairline" />
</div>
),
});
const openingMessages = startupAssistantMessages(startup);
const records =
(startup.runtimeRecords?.length ?? 0) > 0
? startup.runtimeRecords ?? []
: legacyStartupRuntimeRecords(startup);
const firstMessageStartedIndex = records.findIndex(
(event) => event.event === "message_started",
);
const firstMessageCompletedIndex = records.findIndex(
(event) => event.event === "message_completed",
);
const appendOpeningMessage = (message: string, index: number) => {
items.push({
id: `startup-message-${index}`,
kind: "message",
message: {
id: `startup-message-${index}`,
role: "assistant",
content: message,
},
});
};
const appendRuntimeRecord = (
event: Record<string, unknown>,
index: number,
) => {
const id = runtimeRecordKey(event, "startup-record", index);
items.push({
id,
kind: "runtime-record",
record: {
id,
event,
timestamp:
typeof event.timestamp === "string" ? event.timestamp : undefined,
},
});
};
if (firstMessageStartedIndex < 0) {
records.forEach(appendRuntimeRecord);
openingMessages.forEach(appendOpeningMessage);
} else {
records.forEach((event, index) => {
appendRuntimeRecord(event, index);
if (index === firstMessageStartedIndex && openingMessages[0]) {
appendOpeningMessage(openingMessages[0], 0);
}
if (index === firstMessageCompletedIndex) {
openingMessages.slice(1).forEach((message, outputIndex) => {
appendOpeningMessage(message, outputIndex + 1);
});
}
});
if (firstMessageCompletedIndex < 0) {
openingMessages.slice(1).forEach((message, outputIndex) => {
appendOpeningMessage(message, outputIndex + 1);
});
}
}
}
turns.forEach((turn) => {
items.push({
id: `turn-user-${turn.id}`,
kind: "message",
message: {
id: `turn-user-${turn.id}`,
role: "user",
label: `固定输入 · ${turn.index + 1} / ${maxTurns}`,
content: turn.userInput,
},
});
items.push({
id: `turn-assistant-${turn.id}`,
kind: "message",
message: {
id: `turn-assistant-${turn.id}`,
role: "assistant",
content: turn.assistantReply || "(没有生成文字回复)",
},
});
turn.runtimeRecords?.forEach((event, index) => {
const id = runtimeRecordKey(event, `turn-record-${turn.id}`, index);
items.push({
id,
kind: "runtime-record",
record: {
id,
event,
timestamp:
typeof event.timestamp === "string" ? event.timestamp : undefined,
},
});
});
if (turn.toolCalls.length > 0) {
items.push({
id: `turn-tools-${turn.id}`,
kind: "content",
content: <AutoTestToolDetails toolCalls={turn.toolCalls} />,
});
}
if (turn.evaluations.length > 0) {
items.push({
id: `turn-evaluations-${turn.id}`,
kind: "content",
content: (
<div className="w-full space-y-2">
{turn.evaluations.map((evaluation) => (
<EvaluationDetails
key={evaluation.id}
evaluation={evaluation}
/>
))}
</div>
),
});
}
});
return <DebugTimeline items={items} recordContext="auto-test" />;
}
function AutoTestToolDetails({
toolCalls,
}: {
toolCalls: BatchRunCase["turns"][number]["toolCalls"];
}) {
return (
<div className="rounded-xl border border-hairline bg-background/70 px-3 py-2.5">
<div className="text-[10px] font-medium text-muted-soft">{label}</div>
<p className="mt-1 whitespace-pre-wrap break-words text-[11px] leading-5 text-muted-foreground">
{value}
</p>
</div>
<details className="w-full rounded-xl border border-hairline bg-canvas-soft px-3 py-2.5 text-xs">
<summary className="flex cursor-pointer list-none items-center gap-1.5 font-medium text-foreground">
<Wrench size={12} />
工具调用详情
<span className="ml-auto text-[10px] font-normal text-muted-soft">
{toolCalls.length} 次
</span>
</summary>
<div className="mt-2 space-y-3 border-t border-hairline pt-2">
{toolCalls.map((toolCall) => (
<div key={toolCall.id} className="text-[11px] leading-5">
<div>
<code className="text-foreground">{toolCall.functionName}</code>
<span className="ml-2 text-muted-soft">
{toolCall.outcome === "success" ? "成功" : "错误"} ·{" "}
{toolCall.durationMs} ms
</span>
</div>
<div className="mt-1 grid gap-1.5">
<pre className="max-h-28 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-background/80 p-2 font-mono text-[10px] text-muted-foreground">
{toolCall.argumentsJson}
</pre>
<pre className="max-h-28 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-background/80 p-2 font-mono text-[10px] text-muted-foreground">
{toolCall.resultJson}
</pre>
</div>
</div>
))}
</div>
</details>
);
}
@@ -671,7 +782,6 @@ export function AutoTestRunBar({
loading,
onSelectCase,
onClearCase,
onStart,
onStop,
onRerun,
}: {
@@ -682,7 +792,6 @@ export function AutoTestRunBar({
loading: boolean;
onSelectCase: (id: string) => void;
onClearCase: () => void;
onStart: () => void;
onStop: () => void;
onRerun: () => void;
}) {
@@ -818,17 +927,6 @@ export function AutoTestRunBar({
</button>
)}
</div>
<Button
type="button"
size="sm"
className="h-8 shrink-0 gap-1.5 rounded-full px-3"
disabled={Boolean(state.blockedMessage)}
onClick={onStart}
title={state.blockedMessage || "开始测试"}
>
<Play size={13} />
开始测试
</Button>
</div>
{state.blockedMessage && testCase && (
<p className="truncate px-3 text-[10px] leading-4 text-muted-soft">