437 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|