feat(frontend): add Test Suite management and Next Reply case editor

Replace the test-cases placeholder with suite list/detail flows, multi-select and drag reorder, and an edit-only single-step reply editor with keyword/LLM assertions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xin Wang
2026-08-08 17:03:12 +08:00
co-authored by Cursor
parent 2f755969d3
commit 398b20778d
12 changed files with 2267 additions and 10 deletions
@@ -0,0 +1,555 @@
"use client";
/**
* 单步回复 / Next Reply Test 表单主体。
* 带与 prompt mode 相同的锚点导航 + 滚动高亮;标题栏由外层负责。
*/
import {
MessageSquareText,
MessagesSquare,
Plus,
Sparkles,
Target,
Trash2,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SectionCard } from "@/components/editor/section-card";
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
ASSERTION_TYPE_LABEL,
type AssertionType,
type ContextTurn,
type KeywordMatchMode,
type TestCaseKind,
} from "@/data/test-suites";
import { cn } from "@/lib/utils";
export type CaseEditorDraft = {
name: string;
kind: TestCaseKind;
contextTurns: ContextTurn[];
userInput: string;
assertionType: AssertionType;
keywords: string[];
keywordMatchMode: KeywordMatchMode;
llmCriteria: string;
};
type NextReplySectionId = "context" | "user_input" | "expectation";
const NEXT_REPLY_SECTIONS: {
id: NextReplySectionId;
label: string;
}[] = [
{ id: "context", label: "对话上下文" },
{ id: "user_input", label: "当前用户输入" },
{ id: "expectation", label: "结果预期" },
];
/** 单步回复表单主体(含锚点导航) */
export function NextReplyEditorBody({
draft,
onChange,
}: {
draft: CaseEditorDraft;
onChange: (next: CaseEditorDraft) => void;
}) {
const [keywordDraft, setKeywordDraft] = useState("");
const scrollContainerRef = useRef<HTMLDivElement>(null);
const sectionRefs = useRef<Record<NextReplySectionId, HTMLElement | null>>({
context: null,
user_input: null,
expectation: null,
});
const selectedAnchorRef = useRef<NextReplySectionId | null>(null);
const [activeSection, setActiveSection] =
useState<NextReplySectionId>("context");
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const scrollContainer: HTMLDivElement = container;
let animationFrame = 0;
function updateActiveSection() {
if (selectedAnchorRef.current) {
setActiveSection(selectedAnchorRef.current);
return;
}
const containerTop = scrollContainer.getBoundingClientRect().top;
const activationLine = containerTop + 24;
let nextSection: NextReplySectionId = NEXT_REPLY_SECTIONS[0].id;
for (const section of NEXT_REPLY_SECTIONS) {
const element = sectionRefs.current[section.id];
if (element && element.getBoundingClientRect().top <= activationLine) {
nextSection = section.id;
}
}
const reachedBottom =
scrollContainer.scrollHeight > scrollContainer.clientHeight + 8 &&
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
8;
if (reachedBottom) {
nextSection = NEXT_REPLY_SECTIONS[NEXT_REPLY_SECTIONS.length - 1].id;
}
setActiveSection((current) =>
current === nextSection ? current : nextSection,
);
}
function scheduleUpdate() {
window.cancelAnimationFrame(animationFrame);
animationFrame = window.requestAnimationFrame(updateActiveSection);
}
function releaseSelectedAnchor() {
selectedAnchorRef.current = null;
scheduleUpdate();
}
scheduleUpdate();
scrollContainer.addEventListener("scroll", scheduleUpdate, {
passive: true,
});
scrollContainer.addEventListener("wheel", releaseSelectedAnchor, {
passive: true,
});
scrollContainer.addEventListener("touchstart", releaseSelectedAnchor, {
passive: true,
});
window.addEventListener("resize", scheduleUpdate);
return () => {
window.cancelAnimationFrame(animationFrame);
scrollContainer.removeEventListener("scroll", scheduleUpdate);
scrollContainer.removeEventListener("wheel", releaseSelectedAnchor);
scrollContainer.removeEventListener("touchstart", releaseSelectedAnchor);
window.removeEventListener("resize", scheduleUpdate);
};
}, []);
function scrollToSection(sectionId: NextReplySectionId) {
const container = scrollContainerRef.current;
const section = sectionRefs.current[sectionId];
if (!container || !section) return;
const containerTop = container.getBoundingClientRect().top;
const sectionTop = section.getBoundingClientRect().top;
selectedAnchorRef.current = sectionId;
setActiveSection(sectionId);
container.scrollTo({
top: container.scrollTop + sectionTop - containerTop,
behavior: "smooth",
});
}
function updateContext(index: number, patch: Partial<ContextTurn>) {
onChange({
...draft,
contextTurns: draft.contextTurns.map((turn, turnIndex) =>
turnIndex === index ? { ...turn, ...patch } : turn,
),
});
}
function addContextTurn(role: "agent" | "user") {
onChange({
...draft,
contextTurns: [...draft.contextTurns, { role, content: "" }],
});
}
function removeContextTurn(index: number) {
onChange({
...draft,
contextTurns: draft.contextTurns.filter(
(_, turnIndex) => turnIndex !== index,
),
});
}
function addKeyword(raw: string) {
const value = raw.trim();
if (!value) return;
if (draft.keywords.includes(value)) {
setKeywordDraft("");
return;
}
onChange({
...draft,
keywords: [...draft.keywords, value],
});
setKeywordDraft("");
}
function removeKeyword(index: number) {
onChange({
...draft,
keywords: draft.keywords.filter((_, itemIndex) => itemIndex !== index),
});
}
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SectionAnchorTabs
ariaLabel="测试用例分区"
sections={NEXT_REPLY_SECTIONS}
activeSectionId={activeSection}
onSelect={(sectionId) =>
scrollToSection(sectionId as NextReplySectionId)
}
className="bg-background px-4 pt-3 sm:px-6 sm:pt-4 lg:px-8"
contentClassName="mx-auto w-full max-w-3xl"
/>
<div
ref={scrollContainerRef}
className="scrollbar-subtle min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none bg-background px-4 pb-6 pt-3 sm:px-6 sm:pb-8 lg:px-8 lg:pb-10"
>
<div className="mx-auto max-w-3xl space-y-3">
{/* 对话上下文 */}
<section
ref={(element) => {
sectionRefs.current.context = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<MessagesSquare size={15} />}
title="对话上下文"
description="可选:运行前的历史轮次,默认为空"
action={
<div className="flex gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 border-hairline-strong text-xs text-muted-foreground"
onClick={() => addContextTurn("agent")}
>
<Plus size={13} />
Agent
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 border-hairline-strong text-xs text-muted-foreground"
onClick={() => addContextTurn("user")}
>
<Plus size={13} />
User
</Button>
</div>
}
>
{draft.contextTurns.length === 0 ? (
<p className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-6 text-center text-xs text-muted-soft">
默认无上下文,可按需添加 Agent / User 轮次
</p>
) : (
<div className="space-y-2">
{draft.contextTurns.map((turn, index) => (
<div key={index} className="flex items-start gap-2">
<Select
value={turn.role}
onValueChange={(value) =>
updateContext(index, {
role: value as "agent" | "user",
})
}
>
<SelectTrigger className="h-9 w-[96px] shrink-0 border-hairline-strong bg-background text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="agent">Agent</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<Input
value={turn.content}
onChange={(event) =>
updateContext(index, {
content: event.target.value,
})
}
placeholder={
turn.role === "agent"
? "请您简单描述一下事发经过。"
: "用户说的话…"
}
className="border-hairline-strong bg-background"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-soft hover:text-destructive"
onClick={() => removeContextTurn(index)}
aria-label="删除上下文"
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
)}
</SectionCard>
</section>
{/* 当前用户输入 */}
<section
ref={(element) => {
sectionRefs.current.user_input = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<MessageSquareText size={15} />}
title="当前用户输入"
description="本轮要测的用户原话"
>
<div className="flex items-start gap-2">
<span className="mt-2 w-14 shrink-0 text-xs font-medium text-muted-foreground">
User
</span>
<Input
value={draft.userInput}
onChange={(event) =>
onChange({ ...draft, userInput: event.target.value })
}
placeholder="喂"
className="border-hairline-strong bg-background"
/>
</div>
</SectionCard>
</section>
{/* 结果预期 */}
<section
ref={(element) => {
sectionRefs.current.expectation = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<Target size={15} />}
title="结果预期"
description="用关键词或 LLM 标准判断 Agent 下一句回复是否合格"
>
<div className="flex flex-wrap gap-2">
{(
[
{
value: "keyword" as const,
label: ASSERTION_TYPE_LABEL.keyword,
hint: "匹配回复中的词",
},
{
value: "llm" as const,
label: ASSERTION_TYPE_LABEL.llm,
hint: "用自然语言标准评判",
},
] as const
).map((option) => {
const active = draft.assertionType === option.value;
return (
<button
key={option.value}
type="button"
onClick={() =>
onChange({
...draft,
assertionType: option.value as AssertionType,
})
}
className={cn(
"flex min-w-[140px] flex-1 flex-col items-start gap-0.5 rounded-2xl border px-3.5 py-3 text-left transition-colors",
active
? "border-hairline-strong bg-surface-strong"
: "border-hairline bg-background hover:bg-canvas-soft/80",
)}
>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
{option.value === "llm" ? (
<Sparkles size={14} className="text-muted-foreground" />
) : (
<Target size={14} className="text-muted-foreground" />
)}
{option.label}
</span>
<span className="text-xs text-muted-soft">
{option.hint}
</span>
</button>
);
})}
</div>
{draft.assertionType === "keyword" ? (
<div className="space-y-4 border-t border-hairline pt-4">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">
关键词
</div>
<div className="flex min-h-11 flex-wrap items-center gap-2 rounded-2xl border border-hairline-strong bg-background px-3 py-2">
{draft.keywords.map((keyword, index) => (
<span
key={`${keyword}-${index}`}
className="inline-flex h-7 items-center gap-1 rounded-full bg-surface-strong px-2.5 text-xs text-foreground"
>
{keyword}
<button
type="button"
className="text-muted-soft hover:text-destructive"
onClick={() => removeKeyword(index)}
aria-label={`删除关键词 ${keyword}`}
>
<X size={12} />
</button>
</span>
))}
<Input
value={keywordDraft}
onChange={(event) =>
setKeywordDraft(event.target.value)
}
onKeyDown={(event) => {
if (
event.key === "Enter" ||
event.key === "," ||
event.key === ","
) {
event.preventDefault();
addKeyword(keywordDraft);
} else if (
event.key === "Backspace" &&
!keywordDraft &&
draft.keywords.length > 0
) {
removeKeyword(draft.keywords.length - 1);
}
}}
onBlur={() => addKeyword(keywordDraft)}
placeholder={
draft.keywords.length === 0
? "输入后回车,例如:我在"
: "继续添加…"
}
className="h-7 min-w-[140px] flex-1 border-0 bg-transparent px-1 shadow-none focus-visible:ring-0"
/>
</div>
<p className="text-xs text-muted-soft">
回车或逗号添加;期望 Agent 回复中出现这些词。
</p>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">
匹配方式
</div>
<div className="flex flex-wrap gap-2">
{(
[
{
value: "any" as const,
label: "任一匹配",
hint: "命中其中一个即可",
},
{
value: "all" as const,
label: "全部匹配",
hint: "需要全部出现",
},
] as const
).map((option) => {
const active =
draft.keywordMatchMode === option.value;
return (
<button
key={option.value}
type="button"
onClick={() =>
onChange({
...draft,
keywordMatchMode:
option.value as KeywordMatchMode,
})
}
className={cn(
"rounded-full border px-3.5 py-1.5 text-xs transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-hairline-strong text-muted-foreground hover:text-foreground",
)}
>
{option.label}
<span
className={cn(
"ml-1.5",
active
? "text-primary-foreground/80"
: "text-muted-soft",
)}
>
· {option.hint}
</span>
</button>
);
})}
</div>
</div>
</div>
) : (
<div className="space-y-2 border-t border-hairline pt-4">
<div className="text-sm font-medium text-foreground">
判断标准
</div>
<Textarea
value={draft.llmCriteria}
onChange={(event) =>
onChange({
...draft,
llmCriteria: event.target.value,
})
}
rows={5}
placeholder={
"Agent 应当回应用户的主动唤醒,同时继续引导用户描述事故情况,不应该无响应。"
}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background"
/>
<p className="text-xs text-muted-soft">
用自然语言写清「什么算通过」,后续由 LLM 评判下一句回复。
</p>
</div>
)}
</SectionCard>
</section>
</div>
</div>
</div>
);
}