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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user