1274 lines
38 KiB
TypeScript
1274 lines
38 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Debug Drawer 的真实自动测试客户端。
|
|
*
|
|
* 它复用批量测试的持久化 Test Run API,但每次只运行一个用例。这样 Drawer
|
|
* 与批量测试共享同一套 Pipeline、工具 Mock、评估器和运行记录,不维护第二套
|
|
* 测试语义。
|
|
*/
|
|
|
|
import type React from "react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
ArrowDown,
|
|
Check,
|
|
ChevronDown,
|
|
FlaskConical,
|
|
Loader2,
|
|
Play,
|
|
Square,
|
|
TriangleAlert,
|
|
Wrench,
|
|
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 {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { SearchInput } from "@/components/ui/search-input";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import type {
|
|
BatchEvaluationResult,
|
|
BatchRunCase,
|
|
BatchRunSnapshot,
|
|
BatchStartupResult,
|
|
} from "@/data/batch-run";
|
|
import {
|
|
getTestCaseValidationMessage,
|
|
TEST_CASE_INPUT_MODE_SHORT_LABEL,
|
|
type TestCase,
|
|
type TestSuite,
|
|
} from "@/data/test-suites";
|
|
import {
|
|
batchRunsApi,
|
|
modelResourcesApi,
|
|
testAssetsApi,
|
|
testCasesApi,
|
|
testSuitesApi,
|
|
type ModelResource,
|
|
} from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const RUN_POLL_MS = 1000;
|
|
const AUTO_FOLLOW_BOTTOM_THRESHOLD_PX = 32;
|
|
|
|
export type AutoTestPhase =
|
|
| "idle"
|
|
| "ready"
|
|
| "starting"
|
|
| "running"
|
|
| "passed"
|
|
| "failed"
|
|
| "error"
|
|
| "cancelled";
|
|
|
|
export type AutoTestRunState = {
|
|
phase: AutoTestPhase;
|
|
selectedId: string | null;
|
|
currentTurn: number;
|
|
maxTurns: number;
|
|
run: BatchRunSnapshot | null;
|
|
runCase: BatchRunCase | null;
|
|
error: string;
|
|
stopping: boolean;
|
|
blockedMessage: string;
|
|
evaluatorName: string;
|
|
};
|
|
|
|
type AutoTestRunnerOptions = {
|
|
assistantId: string | null;
|
|
enabled: boolean;
|
|
hasUnsavedChanges: boolean;
|
|
dynamicVariables: Record<string, string | number | boolean>;
|
|
dynamicVariablesError: string;
|
|
};
|
|
|
|
function isRunFinished(run: BatchRunSnapshot): boolean {
|
|
return (
|
|
Boolean(run.finishedAt) ||
|
|
run.status === "completed" ||
|
|
run.status === "cancelled"
|
|
);
|
|
}
|
|
|
|
function runnableMessage(item: TestCase): string {
|
|
if (item.inputMode !== "fixed_script_text") {
|
|
return "当前自动测试仅支持固定脚本 · 多模态";
|
|
}
|
|
return getTestCaseValidationMessage(item) ?? "";
|
|
}
|
|
|
|
// ─── Mode tabs ──────────────────────────────────────────────────────────────
|
|
|
|
export function DebugModeTabs({
|
|
mode,
|
|
onChange,
|
|
}: {
|
|
mode: "manual" | "auto";
|
|
onChange: (mode: "manual" | "auto") => void;
|
|
}) {
|
|
return (
|
|
<Tabs
|
|
value={mode}
|
|
onValueChange={(value) => onChange(value as "manual" | "auto")}
|
|
className="w-full gap-0"
|
|
>
|
|
<TabsList className="grid w-full grid-cols-2 bg-surface-strong">
|
|
<TabsTrigger value="manual">手动测试</TabsTrigger>
|
|
<TabsTrigger value="auto">自动测试</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
);
|
|
}
|
|
|
|
// ─── Case picker ────────────────────────────────────────────────────────────
|
|
|
|
function CasePicker({
|
|
suites,
|
|
cases,
|
|
selectedId,
|
|
loading,
|
|
onSelect,
|
|
trigger,
|
|
}: {
|
|
suites: TestSuite[];
|
|
cases: TestCase[];
|
|
selectedId: string | null;
|
|
loading: boolean;
|
|
onSelect: (id: string) => void;
|
|
trigger: React.ReactNode;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
|
|
const visibleSuites = useMemo(
|
|
() =>
|
|
suites
|
|
.map((suite) => ({
|
|
suite,
|
|
cases: cases.filter((item) => item.suiteId === suite.id),
|
|
}))
|
|
.map((group) => ({
|
|
...group,
|
|
cases: group.cases.filter((item) => {
|
|
if (!normalizedQuery) return true;
|
|
return [
|
|
item.name,
|
|
item.description,
|
|
TEST_CASE_INPUT_MODE_SHORT_LABEL[item.inputMode],
|
|
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
|
|
}),
|
|
}))
|
|
.filter((group) => group.cases.length > 0),
|
|
[cases, normalizedQuery, suites],
|
|
);
|
|
|
|
return (
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (!next) setQuery("");
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
|
<PopoverContent
|
|
align="start"
|
|
side="top"
|
|
className="w-[min(380px,calc(100vw-2rem))] space-y-3 rounded-2xl p-3"
|
|
>
|
|
<div className="px-1 text-sm font-medium text-foreground">
|
|
选择测试用例
|
|
</div>
|
|
<SearchInput
|
|
value={query}
|
|
onChange={setQuery}
|
|
placeholder="搜索测试用例"
|
|
className="w-full"
|
|
/>
|
|
<div className="scrollbar-subtle max-h-72 space-y-3 overflow-y-auto pr-0.5">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
加载真实测试用例…
|
|
</div>
|
|
) : visibleSuites.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-hairline-strong px-3 py-6 text-center text-xs text-muted-foreground">
|
|
{cases.length === 0 ? "还没有测试用例" : "没有匹配的测试用例"}
|
|
</div>
|
|
) : (
|
|
visibleSuites.map(({ suite, cases: suiteCases }) => (
|
|
<section key={suite.id} className="space-y-1">
|
|
<div className="flex items-center justify-between px-2 text-[11px] font-medium text-muted-soft">
|
|
<span className="truncate">{suite.name}</span>
|
|
<span className="tabular-nums">{suiteCases.length}</span>
|
|
</div>
|
|
{suiteCases.map((item) => {
|
|
const unsupported = Boolean(runnableMessage(item));
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => {
|
|
onSelect(item.id);
|
|
setOpen(false);
|
|
}}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left transition-colors",
|
|
item.id === selectedId
|
|
? "bg-surface-strong"
|
|
: "hover:bg-canvas-soft",
|
|
)}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
|
{item.name}
|
|
</span>
|
|
<Badge
|
|
variant="secondary"
|
|
className={cn(
|
|
"shrink-0 rounded-full px-2 py-0 text-[10px] font-medium",
|
|
unsupported && "text-muted-soft",
|
|
)}
|
|
>
|
|
{TEST_CASE_INPUT_MODE_SHORT_LABEL[item.inputMode]}
|
|
</Badge>
|
|
</button>
|
|
);
|
|
})}
|
|
</section>
|
|
))
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// ─── Result panel ───────────────────────────────────────────────────────────
|
|
|
|
function AutoFollowViewport({
|
|
children,
|
|
followKey,
|
|
}: {
|
|
children: React.ReactNode;
|
|
followKey: string;
|
|
}) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const isFollowingRef = useRef(true);
|
|
const [isFollowing, setIsFollowing] = useState(true);
|
|
|
|
const updateFollowing = useCallback((next: boolean) => {
|
|
isFollowingRef.current = next;
|
|
setIsFollowing((current) => (current === next ? current : next));
|
|
}, []);
|
|
|
|
const scrollToLatest = useCallback(() => {
|
|
const viewport = scrollRef.current;
|
|
if (!viewport) return;
|
|
viewport.scrollTop = viewport.scrollHeight;
|
|
}, []);
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const viewport = scrollRef.current;
|
|
if (!viewport) return;
|
|
const distanceFromBottom =
|
|
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
|
updateFollowing(distanceFromBottom <= AUTO_FOLLOW_BOTTOM_THRESHOLD_PX);
|
|
}, [updateFollowing]);
|
|
|
|
useEffect(() => {
|
|
if (!isFollowingRef.current) return;
|
|
const frame = window.requestAnimationFrame(scrollToLatest);
|
|
return () => window.cancelAnimationFrame(frame);
|
|
}, [followKey, scrollToLatest]);
|
|
|
|
// Runtime cards and images can grow after the test snapshot is rendered.
|
|
useEffect(() => {
|
|
const content = contentRef.current;
|
|
if (!content) return;
|
|
const observer = new ResizeObserver(() => {
|
|
if (isFollowingRef.current) scrollToLatest();
|
|
});
|
|
observer.observe(content);
|
|
return () => observer.disconnect();
|
|
}, [scrollToLatest]);
|
|
|
|
const resumeFollowing = useCallback(() => {
|
|
updateFollowing(true);
|
|
scrollToLatest();
|
|
}, [scrollToLatest, updateFollowing]);
|
|
|
|
return (
|
|
<div className="relative flex min-h-0 flex-1">
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto px-5 py-4"
|
|
>
|
|
<div ref={contentRef}>{children}</div>
|
|
</div>
|
|
{!isFollowing && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={resumeFollowing}
|
|
className="absolute bottom-4 left-1/2 h-8 -translate-x-1/2 gap-1.5 rounded-full border border-hairline bg-background/95 px-3 text-xs shadow-md backdrop-blur"
|
|
>
|
|
<ArrowDown size={13} />
|
|
回到最新
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AutoTestLivePanel({
|
|
testCase,
|
|
state,
|
|
onStart,
|
|
}: {
|
|
testCase: TestCase | null;
|
|
state: AutoTestRunState;
|
|
onStart: () => void;
|
|
}) {
|
|
if (!testCase) {
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
if (state.phase === "idle" || state.phase === "ready") {
|
|
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="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 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">
|
|
{testCase.description}
|
|
</p>
|
|
)}
|
|
{validationMessage ? (
|
|
<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>
|
|
) : 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>
|
|
);
|
|
}
|
|
|
|
if (
|
|
state.phase === "error" &&
|
|
state.runCase?.executionError &&
|
|
state.runCase.turns.length === 0
|
|
) {
|
|
return (
|
|
<ErrorPanel
|
|
title={state.runCase.executionError.code}
|
|
description={state.runCase.executionError.message}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (state.phase === "error" && state.error && !state.runCase) {
|
|
return <ErrorPanel title="自动测试启动失败" description={state.error} />;
|
|
}
|
|
|
|
const turns = state.runCase?.turns ?? [];
|
|
const startup = state.runCase?.startup;
|
|
const autoFollowKey = [
|
|
state.phase,
|
|
startup?.completed ? 1 : 0,
|
|
startup?.assistantOutputs.length ?? 0,
|
|
startup?.runtimeRecords?.length ?? 0,
|
|
...turns.map(
|
|
(turn) =>
|
|
`${turn.id}:${turn.runtimeRecords?.length ?? 0}:${turn.toolCalls.length}:${turn.evaluations.length}`,
|
|
),
|
|
state.runCase?.overallCriteria.length ?? 0,
|
|
].join("|");
|
|
const autoFollowResetKey = state.run?.id ?? state.selectedId ?? "auto-test";
|
|
|
|
if (turns.length === 0) {
|
|
if (startup?.completed) {
|
|
return (
|
|
<AutoFollowViewport
|
|
key={autoFollowResetKey}
|
|
followKey={autoFollowKey}
|
|
>
|
|
<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 轮固定输入…
|
|
</div>
|
|
</AutoFollowViewport>
|
|
);
|
|
}
|
|
return (
|
|
<EmptyPanel
|
|
icon={<Loader2 size={20} className="animate-spin" />}
|
|
title={state.stopping ? "正在停止测试…" : "正在启动真实 Pipeline…"}
|
|
description="测试在后端运行,结果会自动同步到这里。"
|
|
/>
|
|
);
|
|
}
|
|
|
|
const evaluating =
|
|
state.phase === "running" && state.currentTurn >= state.maxTurns;
|
|
|
|
return (
|
|
<AutoFollowViewport
|
|
key={autoFollowResetKey}
|
|
followKey={autoFollowKey}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<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">
|
|
<Loader2 size={13} className="animate-spin" />
|
|
{state.stopping
|
|
? "正在停止测试…"
|
|
: evaluating
|
|
? "对话已完成,正在评估结果…"
|
|
: `正在执行第 ${state.currentTurn + 1} 轮…`}
|
|
</div>
|
|
)}
|
|
|
|
{state.phase === "error" && state.runCase?.executionError && (
|
|
<div className="rounded-xl border border-amber-500/25 bg-amber-500/[0.06] px-3 py-2.5 text-xs leading-5 text-amber-600 dark:text-amber-400">
|
|
<span className="font-medium">
|
|
{state.runCase.executionError.code}
|
|
</span>
|
|
<span> · {state.runCase.executionError.message}</span>
|
|
</div>
|
|
)}
|
|
|
|
{state.runCase && state.runCase.overallCriteria.length > 0 && (
|
|
<div className="space-y-2 border-t border-hairline pt-3">
|
|
<div className="text-[11px] font-medium text-muted-soft">
|
|
整体评估标准
|
|
</div>
|
|
{state.runCase.overallCriteria.map((evaluation) => (
|
|
<EvaluationDetails
|
|
key={evaluation.id}
|
|
evaluation={evaluation}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AutoFollowViewport>
|
|
);
|
|
}
|
|
|
|
function startupAssistantMessages(startup: BatchStartupResult): string[] {
|
|
const greeting = startup.greeting.trim();
|
|
const greetingText = normalizeDisplayText(greeting);
|
|
const seen = new Set<string>();
|
|
const assistantMessages = startup.assistantOutputs.filter((output) => {
|
|
const normalizedOutput = normalizeDisplayText(output);
|
|
if (
|
|
!normalizedOutput ||
|
|
normalizedOutput === greetingText ||
|
|
seen.has(normalizedOutput)
|
|
) {
|
|
return false;
|
|
}
|
|
seen.add(normalizedOutput);
|
|
return true;
|
|
});
|
|
return [greeting, ...assistantMessages].filter(Boolean);
|
|
}
|
|
|
|
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 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 ?? [];
|
|
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,
|
|
attachments: turn.image
|
|
? [
|
|
{
|
|
id: turn.image.assetId,
|
|
type: "image",
|
|
url: testAssetsApi.contentUrl(turn.image.assetId),
|
|
alt: turn.image.filename,
|
|
},
|
|
]
|
|
: undefined,
|
|
},
|
|
});
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function EvaluationDetails({
|
|
evaluation,
|
|
}: {
|
|
evaluation: BatchEvaluationResult;
|
|
}) {
|
|
const passed = evaluation.status === "pass";
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-xl border bg-canvas-soft px-3 py-2.5",
|
|
passed
|
|
? "border-hairline"
|
|
: "border-destructive/25 bg-destructive/5",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full",
|
|
passed
|
|
? "bg-success/10 text-success"
|
|
: "bg-destructive/10 text-destructive",
|
|
)}
|
|
>
|
|
{passed ? <Check size={10} /> : <X size={10} />}
|
|
</span>
|
|
<div className="min-w-0 flex-1 text-[11px] font-medium text-foreground">
|
|
{evaluation.label}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"text-[10px] font-medium",
|
|
passed ? "text-success" : "text-destructive",
|
|
)}
|
|
>
|
|
{passed ? "通过" : "未通过"}
|
|
</span>
|
|
</div>
|
|
<dl className="mt-2 grid grid-cols-[52px_minmax(0,1fr)] gap-x-2 gap-y-1 text-[11px] leading-5">
|
|
<dt className="text-muted-soft">评估标准</dt>
|
|
<dd className="whitespace-pre-wrap break-words text-muted-foreground">
|
|
{evaluation.expected || "(未提供评估标准)"}
|
|
</dd>
|
|
<dt className="text-muted-soft">实际结果</dt>
|
|
<dd className="whitespace-pre-wrap break-words text-muted-foreground">
|
|
{evaluation.actual || "(没有可评估内容)"}
|
|
</dd>
|
|
{evaluation.reason && (
|
|
<>
|
|
<dt className={passed ? "text-muted-soft" : "text-destructive"}>
|
|
判断说明
|
|
</dt>
|
|
<dd className="whitespace-pre-wrap break-words text-foreground">
|
|
{evaluation.reason}
|
|
</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyPanel({
|
|
icon,
|
|
title,
|
|
description,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
description: string;
|
|
}) {
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 py-8 text-center">
|
|
<span className="text-muted-soft">{icon}</span>
|
|
<div className="mt-2 text-sm font-medium text-foreground">{title}</div>
|
|
<p className="mx-auto mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
|
|
{description}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorPanel({ title, description }: { title: string; description: string }) {
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 py-8 text-center">
|
|
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
|
<TriangleAlert size={18} />
|
|
</span>
|
|
<div className="mt-3 text-sm font-medium text-foreground">{title}</div>
|
|
<p className="mx-auto mt-1 max-w-sm text-xs leading-5 text-muted-foreground">
|
|
{description}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Bottom run bar ─────────────────────────────────────────────────────────
|
|
|
|
export function AutoTestRunBar({
|
|
state,
|
|
testCase,
|
|
suites,
|
|
cases,
|
|
loading,
|
|
onSelectCase,
|
|
onClearCase,
|
|
onStop,
|
|
onRerun,
|
|
}: {
|
|
state: AutoTestRunState;
|
|
testCase: TestCase | null;
|
|
suites: TestSuite[];
|
|
cases: TestCase[];
|
|
loading: boolean;
|
|
onSelectCase: (id: string) => void;
|
|
onClearCase: () => void;
|
|
onStop: () => void;
|
|
onRerun: () => void;
|
|
}) {
|
|
const pickerTrigger = (
|
|
<button
|
|
type="button"
|
|
className="flex min-w-0 flex-1 items-center gap-2 rounded-full px-2 py-1.5 text-left transition-colors hover:bg-canvas-soft"
|
|
>
|
|
{loading ? (
|
|
<Loader2 size={15} className="shrink-0 animate-spin text-muted-soft" />
|
|
) : (
|
|
<FlaskConical size={16} className="shrink-0 text-muted-soft" />
|
|
)}
|
|
<span
|
|
className={cn(
|
|
"min-w-0 flex-1 truncate text-sm",
|
|
testCase ? "font-medium text-foreground" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{loading ? "加载测试用例…" : testCase?.name ?? "选择测试用例…"}
|
|
</span>
|
|
<ChevronDown size={14} className="shrink-0 text-muted-soft" />
|
|
</button>
|
|
);
|
|
|
|
if (state.phase === "starting" || state.phase === "running") {
|
|
return (
|
|
<div className="flex min-h-11 items-center gap-2 rounded-[1.4rem] border border-hairline-strong bg-background px-3">
|
|
<Loader2 size={15} className="shrink-0 animate-spin text-primary" />
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
|
{testCase?.name}
|
|
</span>
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{state.stopping
|
|
? "停止中"
|
|
: state.phase === "starting"
|
|
? "启动中"
|
|
: `${state.currentTurn} / ${state.maxTurns}`}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
className="h-8 shrink-0 gap-1.5 rounded-full px-3"
|
|
disabled={state.stopping}
|
|
onClick={onStop}
|
|
>
|
|
<Square size={11} className="fill-current" />
|
|
停止
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (
|
|
state.phase === "passed" ||
|
|
state.phase === "failed" ||
|
|
state.phase === "error" ||
|
|
state.phase === "cancelled"
|
|
) {
|
|
const passed = state.phase === "passed";
|
|
const label = passed
|
|
? `PASS · ${state.currentTurn} 轮`
|
|
: state.phase === "failed"
|
|
? "FAIL"
|
|
: state.phase === "cancelled"
|
|
? "已停止"
|
|
: "执行错误";
|
|
return (
|
|
<div className="flex min-h-11 items-center gap-2 rounded-[1.4rem] border border-hairline-strong bg-background px-3">
|
|
<span
|
|
className={cn(
|
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full",
|
|
passed
|
|
? "bg-success/15 text-success"
|
|
: state.phase === "failed"
|
|
? "bg-destructive/15 text-destructive"
|
|
: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
|
)}
|
|
>
|
|
{passed ? <Check size={12} /> : <X size={12} />}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
|
{testCase?.name}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"shrink-0 text-xs font-medium",
|
|
passed
|
|
? "text-success"
|
|
: state.phase === "failed"
|
|
? "text-destructive"
|
|
: "text-amber-600 dark:text-amber-400",
|
|
)}
|
|
>
|
|
{label}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
className="h-8 shrink-0 rounded-full px-3 text-xs"
|
|
disabled={Boolean(state.blockedMessage)}
|
|
onClick={onRerun}
|
|
title={state.blockedMessage || "再次运行"}
|
|
>
|
|
再次运行
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="flex h-11 items-center gap-1.5 rounded-[1.4rem] border border-hairline-strong bg-background px-2">
|
|
<div className="flex min-w-0 flex-1 items-center">
|
|
<CasePicker
|
|
suites={suites}
|
|
cases={cases}
|
|
selectedId={state.selectedId}
|
|
loading={loading}
|
|
onSelect={onSelectCase}
|
|
trigger={pickerTrigger}
|
|
/>
|
|
{testCase && (
|
|
<button
|
|
type="button"
|
|
onClick={onClearCase}
|
|
aria-label="清除当前用例"
|
|
className="mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{state.blockedMessage && testCase && (
|
|
<p className="truncate px-3 text-[10px] leading-4 text-muted-soft">
|
|
{state.blockedMessage}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Hook: real single-case run ─────────────────────────────────────────────
|
|
|
|
export function useAutoTestRunner({
|
|
assistantId,
|
|
enabled,
|
|
hasUnsavedChanges,
|
|
dynamicVariables,
|
|
dynamicVariablesError,
|
|
}: AutoTestRunnerOptions) {
|
|
const [suites, setSuites] = useState<TestSuite[]>([]);
|
|
const [cases, setCases] = useState<TestCase[]>([]);
|
|
const [evaluatorModels, setEvaluatorModels] = useState<ModelResource[]>([]);
|
|
const [evaluatorModelId, setEvaluatorModelId] = useState("");
|
|
const [catalogLoading, setCatalogLoading] = useState(true);
|
|
const [catalogError, setCatalogError] = useState("");
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [run, setRun] = useState<BatchRunSnapshot | null>(null);
|
|
const activeRunRef = useRef<BatchRunSnapshot | null>(null);
|
|
const [starting, setStarting] = useState(false);
|
|
const [stopping, setStopping] = useState(false);
|
|
const [runError, setRunError] = useState("");
|
|
|
|
useEffect(() => {
|
|
activeRunRef.current = run;
|
|
}, [run]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
const activeRun = activeRunRef.current;
|
|
if (activeRun && !isRunFinished(activeRun)) {
|
|
void batchRunsApi.cancel(activeRun.id);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
let disposed = false;
|
|
|
|
void Promise.all([
|
|
testSuitesApi.list(),
|
|
testCasesApi.list(),
|
|
modelResourcesApi.list(),
|
|
])
|
|
.then(([loadedSuites, loadedCases, resources]) => {
|
|
if (disposed) return;
|
|
const llms = resources.filter(
|
|
(item) => item.capability === "LLM" && item.enabled,
|
|
);
|
|
setSuites(loadedSuites);
|
|
setCases(loadedCases);
|
|
setEvaluatorModels(llms);
|
|
setCatalogError("");
|
|
const evaluator = llms.find((item) => item.isDefault) ?? llms[0];
|
|
setEvaluatorModelId(evaluator?.id ?? "");
|
|
})
|
|
.catch((error) => {
|
|
if (disposed) return;
|
|
setCatalogError(
|
|
error instanceof Error ? error.message : "加载测试用例失败",
|
|
);
|
|
})
|
|
.finally(() => {
|
|
if (!disposed) setCatalogLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
};
|
|
}, [enabled]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !run || isRunFinished(run)) return;
|
|
let disposed = false;
|
|
let timer = 0;
|
|
|
|
async function poll() {
|
|
try {
|
|
const snapshot = await batchRunsApi.get(run!.id);
|
|
if (disposed) return;
|
|
setRun(snapshot);
|
|
setRunError("");
|
|
if (isRunFinished(snapshot)) {
|
|
setStopping(false);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
if (!disposed) {
|
|
setRunError(
|
|
error instanceof Error ? error.message : "刷新测试结果失败",
|
|
);
|
|
}
|
|
}
|
|
if (!disposed) timer = window.setTimeout(() => void poll(), RUN_POLL_MS);
|
|
}
|
|
|
|
timer = window.setTimeout(() => void poll(), RUN_POLL_MS);
|
|
return () => {
|
|
disposed = true;
|
|
window.clearTimeout(timer);
|
|
};
|
|
}, [enabled, run]);
|
|
|
|
const selectedCase = useMemo(
|
|
() => cases.find((item) => item.id === selectedId) ?? null,
|
|
[cases, selectedId],
|
|
);
|
|
const evaluator = useMemo(
|
|
() => evaluatorModels.find((item) => item.id === evaluatorModelId) ?? null,
|
|
[evaluatorModelId, evaluatorModels],
|
|
);
|
|
const runCase = run?.cases[0] ?? null;
|
|
|
|
let blockedMessage = "";
|
|
if (catalogLoading) blockedMessage = "正在加载测试用例…";
|
|
else if (catalogError) blockedMessage = catalogError;
|
|
else if (!assistantId) blockedMessage = "请先保存助手,再开始自动测试";
|
|
else if (hasUnsavedChanges) blockedMessage = "请先保存当前改动,再开始自动测试";
|
|
else if (dynamicVariablesError) blockedMessage = dynamicVariablesError;
|
|
else if (!selectedCase) blockedMessage = "请先选择测试用例";
|
|
else if (runnableMessage(selectedCase)) {
|
|
blockedMessage = runnableMessage(selectedCase);
|
|
} else if (!evaluatorModelId) blockedMessage = "请先配置可用的 LLM 评估模型";
|
|
|
|
let phase: AutoTestPhase = selectedCase ? "ready" : "idle";
|
|
if (starting) phase = "starting";
|
|
else if (run) {
|
|
if (run.status === "cancelled" || runCase?.status === "skipped") {
|
|
phase = "cancelled";
|
|
} else if (runCase?.status === "pass") phase = "passed";
|
|
else if (runCase?.status === "fail") phase = "failed";
|
|
else if (runCase?.status === "error") phase = "error";
|
|
else phase = "running";
|
|
} else if (runError) phase = "error";
|
|
|
|
const state: AutoTestRunState = {
|
|
phase,
|
|
selectedId,
|
|
currentTurn: runCase?.turns.length ?? 0,
|
|
maxTurns: selectedCase?.turns.length ?? 0,
|
|
run,
|
|
runCase,
|
|
error: runError,
|
|
stopping,
|
|
blockedMessage,
|
|
evaluatorName: evaluator?.name ?? "",
|
|
};
|
|
|
|
const cancelActiveRun = useCallback(async () => {
|
|
if (!run || isRunFinished(run)) return;
|
|
setStopping(true);
|
|
try {
|
|
const snapshot = await batchRunsApi.cancel(run.id);
|
|
setRun(snapshot);
|
|
setRunError("");
|
|
if (isRunFinished(snapshot)) setStopping(false);
|
|
} catch (error) {
|
|
setRunError(error instanceof Error ? error.message : "停止测试失败");
|
|
setStopping(false);
|
|
}
|
|
}, [run]);
|
|
|
|
const selectCase = useCallback(
|
|
(id: string) => {
|
|
if (run && !isRunFinished(run)) void batchRunsApi.cancel(run.id);
|
|
setSelectedId(id);
|
|
setRun(null);
|
|
setRunError("");
|
|
setStarting(false);
|
|
setStopping(false);
|
|
},
|
|
[run],
|
|
);
|
|
|
|
const clearCase = useCallback(() => {
|
|
if (run && !isRunFinished(run)) void batchRunsApi.cancel(run.id);
|
|
setSelectedId(null);
|
|
setRun(null);
|
|
setRunError("");
|
|
setStarting(false);
|
|
setStopping(false);
|
|
}, [run]);
|
|
|
|
const start = useCallback(async () => {
|
|
if (
|
|
!assistantId ||
|
|
!selectedCase ||
|
|
!evaluatorModelId ||
|
|
blockedMessage ||
|
|
starting
|
|
) {
|
|
return;
|
|
}
|
|
setStarting(true);
|
|
setRun(null);
|
|
setRunError("");
|
|
try {
|
|
const snapshot = await batchRunsApi.create({
|
|
assistantId,
|
|
evaluatorModelResourceId: evaluatorModelId,
|
|
caseIds: [selectedCase.id],
|
|
dynamicVariables,
|
|
title: `调试 · ${selectedCase.name}`,
|
|
config: {
|
|
concurrency: 1,
|
|
timeoutSecs: 60,
|
|
failureStrategy: "continue",
|
|
errorRetryCount: 0,
|
|
errorStrategy: "continue",
|
|
},
|
|
});
|
|
setRun(snapshot);
|
|
} catch (error) {
|
|
setRunError(error instanceof Error ? error.message : "启动自动测试失败");
|
|
} finally {
|
|
setStarting(false);
|
|
}
|
|
}, [
|
|
assistantId,
|
|
blockedMessage,
|
|
dynamicVariables,
|
|
evaluatorModelId,
|
|
selectedCase,
|
|
starting,
|
|
]);
|
|
|
|
const stop = useCallback(() => {
|
|
if (run && !isRunFinished(run)) {
|
|
void cancelActiveRun();
|
|
return;
|
|
}
|
|
setRun(null);
|
|
setRunError("");
|
|
}, [cancelActiveRun, run]);
|
|
|
|
const rerun = useCallback(() => {
|
|
setRun(null);
|
|
setRunError("");
|
|
window.setTimeout(() => void start(), 0);
|
|
}, [start]);
|
|
|
|
return {
|
|
state,
|
|
suites,
|
|
cases,
|
|
catalogLoading,
|
|
selectedCase,
|
|
selectCase,
|
|
clearCase,
|
|
start: () => void start(),
|
|
stop,
|
|
rerun,
|
|
};
|
|
}
|