Files
ai-video-fullstack/frontend/src/components/batch-test/batch-case-detail-drawer.tsx
2026-08-10 13:49:24 +08:00

437 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* 批量测试用例详情面板。
* 与 prompt mode DebugDrawer overlay 相同:作为右侧 flex 半屏,无遮罩/模糊。
*/
import {
CheckCircle2,
Circle,
Loader2,
MessageSquareText,
Pencil,
RotateCcw,
Target,
TriangleAlert,
Wrench,
X,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { BatchCaseStatusBadge } from "@/components/batch-test/batch-case-status";
import { Button } from "@/components/ui/button";
import {
firstEvaluationFailureReason,
type BatchEvaluationResult,
type BatchRunCase,
type BatchToolCallRecord,
type BatchTurnResult,
} from "@/data/batch-run";
import { cn } from "@/lib/utils";
type BatchCaseDetailDrawerProps = {
item: BatchRunCase;
assistantName: string;
onClose: () => void;
onEdit?: () => void;
onRerun?: () => void;
};
export function BatchCaseDetailDrawer({
item,
assistantName,
onClose,
onEdit,
onRerun,
}: BatchCaseDetailDrawerProps) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
const failureReason = firstEvaluationFailureReason(item);
useEffect(() => {
closeButtonRef.current?.focus();
}, [item.id]);
return (
<aside
id="batch-case-detail"
role="complementary"
aria-labelledby="batch-case-detail-title"
className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-l border-hairline bg-card"
>
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
<button
ref={closeButtonRef}
type="button"
aria-label="关闭用例详情"
title="关闭"
className="flex size-10 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
onClick={onClose}
>
<X size={16} />
</button>
<div className="min-w-0 flex-1">
<h2
id="batch-case-detail-title"
className="truncate text-sm font-medium text-foreground"
>
{item.name}
</h2>
<p className="truncate text-xs text-muted-soft">
{assistantName} · Pipeline
</p>
</div>
<BatchCaseStatusBadge status={item.status} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="space-y-5">
<section className="rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5">
<p className="text-xs text-muted-soft"></p>
<p className="mt-1 text-sm text-muted-foreground">
{statusDescription(item)}
</p>
</section>
{(item.status === "waiting" || item.status === "running") && (
<>
<section className="rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5">
<p className="text-xs font-medium text-foreground"></p>
<p className="mt-1 text-xs leading-5 text-muted-soft">
</p>
</section>
<ExecutionTimeline status={item.status} />
</>
)}
{item.status === "error" && item.executionError && (
<section>
<div className="flex items-center gap-2 text-sm font-medium text-amber-600 dark:text-amber-400">
<TriangleAlert size={16} />
<h3></h3>
</div>
<div className="mt-2.5 rounded-2xl border border-amber-500/25 bg-amber-500/[0.06] px-4 py-3.5">
<div className="flex flex-wrap items-center gap-2">
<code className="text-xs font-medium text-foreground">
{item.executionError.code}
</code>
<span className="rounded-full border border-amber-500/25 px-2 py-0.5 text-[10px] text-amber-600 dark:text-amber-400">
{item.executionError.stage}
</span>
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{item.executionError.message}
</p>
<p className="mt-2 text-xs text-muted-soft">
{item.attemptCount}
</p>
</div>
</section>
)}
{(item.status === "pass" || item.status === "fail") && (
<>
<section>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<MessageSquareText size={16} />
<h3></h3>
</div>
<div className="mt-3 space-y-3">
{item.turns.map((turn) => (
<TurnResultCard key={turn.id} turn={turn} />
))}
</div>
</section>
{item.overallCriteria.length > 0 && (
<section>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Target size={16} />
<h3></h3>
</div>
<div className="mt-3 space-y-2">
{item.overallCriteria.map((evaluation) => (
<EvaluationCard
key={evaluation.id}
evaluation={evaluation}
/>
))}
</div>
</section>
)}
</>
)}
{item.status === "fail" && failureReason && (
<DetailSection
icon={<TriangleAlert size={16} />}
title="失败原因"
value={failureReason}
tone="destructive"
/>
)}
{item.status === "skipped" && (
<div className="rounded-2xl border border-dashed border-hairline-strong px-4 py-4 text-sm leading-6 text-muted-foreground">
</div>
)}
</div>
</div>
{(onEdit || onRerun) && (
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-hairline bg-card px-5 py-3">
{onEdit && (
<Button
type="button"
variant="outline"
className="gap-2 rounded-full border-hairline-strong"
onClick={onEdit}
>
<Pencil size={14} />
</Button>
)}
{onRerun && (
<Button
type="button"
className="gap-2 rounded-full"
onClick={onRerun}
>
<RotateCcw size={14} />
</Button>
)}
</div>
)}
</aside>
);
}
function statusDescription(item: BatchRunCase): string {
if (item.status === "waiting") {
return item.attemptCount > 0
? `${item.attemptCount} 次执行发生错误,正在等待重试。`
: "已加入执行队列,正在等待可用并发。";
}
if (item.status === "running") {
return item.attemptCount > 1
? `正在进行第 ${item.attemptCount} 次尝试。`
: "正在请求助手响应,结果尚未生成。";
}
if (item.status === "pass") return "执行与结果判断均已完成。";
if (item.status === "fail") return "执行完成,但实际回复未达到预期。";
if (item.status === "error") return "执行未完成,已用尽配置的重试次数。";
return "本轮运行已结束,该用例未执行。";
}
function TurnResultCard({ turn }: { turn: BatchTurnResult }) {
return (
<article className="overflow-hidden rounded-2xl border border-hairline bg-canvas-soft/60">
<div className="border-b border-hairline px-4 py-3">
<p className="text-xs font-medium text-foreground">
{turn.index + 1}
</p>
<div className="mt-2 grid gap-2 text-xs leading-5 sm:grid-cols-[72px_minmax(0,1fr)]">
<span className="text-muted-soft"></span>
<p className="whitespace-pre-wrap text-muted-foreground">
{turn.userInput}
</p>
<span className="text-muted-soft">Agent </span>
<p className="whitespace-pre-wrap text-muted-foreground">
{turn.assistantReply}
</p>
</div>
</div>
{turn.toolCalls.length > 0 && (
<div className="space-y-2 border-b border-hairline px-4 py-3">
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground">
<Wrench size={13} />
</p>
{turn.toolCalls.map((toolCall) => (
<ToolCallCard key={toolCall.id} toolCall={toolCall} />
))}
</div>
)}
<div className="space-y-2 px-4 py-3">
{turn.evaluations.length === 0 ? (
<p className="text-xs text-muted-soft"></p>
) : (
turn.evaluations.map((evaluation) => (
<EvaluationCard key={evaluation.id} evaluation={evaluation} />
))
)}
</div>
</article>
);
}
function ToolCallCard({ toolCall }: { toolCall: BatchToolCallRecord }) {
return (
<div className="rounded-xl border border-violet-500/20 bg-background/70 p-2.5">
<div className="flex flex-wrap items-center gap-2">
<code className="text-xs text-foreground">{toolCall.functionName}</code>
<span className="rounded-full border border-hairline px-2 py-0.5 text-[10px] text-muted-soft">
{toolCall.outcome === "success" ? "成功" : "错误"} ·{" "}
{toolCall.durationMs} ms
</span>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<CodeResult label="参数" value={toolCall.argumentsJson} />
<CodeResult label="工具返回" value={toolCall.resultJson} />
</div>
</div>
);
}
function CodeResult({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<p className="mb-1 text-[10px] text-muted-soft">{label}</p>
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-canvas-soft p-2 font-mono text-[10px] leading-4 text-muted-foreground">
{value}
</pre>
</div>
);
}
function EvaluationCard({
evaluation,
}: {
evaluation: BatchEvaluationResult;
}) {
const passed = evaluation.status === "pass";
return (
<div
className={cn(
"rounded-xl border bg-background/70 p-3",
passed ? "border-hairline" : "border-destructive/30 bg-destructive/5",
)}
>
<div className="flex items-center gap-2">
{passed ? (
<CheckCircle2 size={14} className="shrink-0 text-success" />
) : (
<TriangleAlert size={14} className="shrink-0 text-destructive" />
)}
<p className="min-w-0 flex-1 text-xs font-medium text-foreground">
{evaluation.label}
</p>
<span
className={cn(
"text-[10px] font-medium",
passed ? "text-success" : "text-destructive",
)}
>
{passed ? "通过" : "失败"}
</span>
</div>
<dl className="mt-2 grid gap-x-2 gap-y-1 text-[11px] leading-4 sm:grid-cols-[52px_minmax(0,1fr)]">
<dt className="text-muted-soft"></dt>
<dd className="whitespace-pre-wrap text-muted-foreground">
{evaluation.expected}
</dd>
<dt className="text-muted-soft"></dt>
<dd className="whitespace-pre-wrap text-muted-foreground">
{evaluation.actual}
</dd>
{evaluation.reason && (
<>
<dt className="text-destructive"></dt>
<dd className="text-foreground">{evaluation.reason}</dd>
</>
)}
</dl>
</div>
);
}
function DetailSection({
icon,
title,
value,
tone = "default",
}: {
icon: React.ReactNode;
title: string;
value: string;
tone?: "default" | "destructive";
}) {
return (
<section>
<div
className={cn(
"flex items-center gap-2 text-sm font-medium text-foreground",
tone === "destructive" && "text-destructive",
)}
>
{icon}
<h3>{title}</h3>
</div>
<p
className={cn(
"mt-2.5 rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5 text-sm leading-6 text-muted-foreground",
tone === "destructive" &&
"border-destructive/20 bg-destructive/5 text-foreground",
)}
>
{value}
</p>
</section>
);
}
function ExecutionTimeline({ status }: { status: "waiting" | "running" }) {
const isRunning = status === "running";
const steps = [
{
label: "载入测试上下文",
detail: isRunning ? "已完成" : "等待开始",
state: isRunning ? "done" : "pending",
},
{
label: "请求助手响应",
detail: isRunning ? "Pipeline 生成中" : "尚未开始",
state: isRunning ? "active" : "pending",
},
{
label: "执行结果判断",
detail: "尚未开始",
state: "pending",
},
] as const;
return (
<section>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Loader2 size={16} className={cn(isRunning && "animate-spin")} />
<h3></h3>
</div>
<ol className="mt-3 rounded-2xl border border-hairline px-4 py-1">
{steps.map((step) => (
<li
key={step.label}
className="flex items-center gap-3 border-b border-hairline py-3.5 last:border-b-0"
>
{step.state === "done" ? (
<CheckCircle2 size={17} className="shrink-0 text-success" />
) : step.state === "active" ? (
<Loader2 size={17} className="shrink-0 animate-spin text-primary" />
) : (
<Circle size={17} className="shrink-0 text-muted-soft" />
)}
<span className="min-w-0 flex-1 text-sm text-foreground">
{step.label}
</span>
<span className="text-xs text-muted-soft">{step.detail}</span>
</li>
))}
</ol>
</section>
);
}