feat: unify debug runtime timelines
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user