Files
ai-video-fullstack/frontend/src/components/batch-test/batch-case-detail-drawer.tsx
T

437 lines
14 KiB
TypeScript

"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>
);
}