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
parent 2f755969d3
commit 398b20778d
12 changed files with 2267 additions and 10 deletions

View File

@@ -9,6 +9,9 @@
"version": "0.1.0",
"dependencies": {
"@daily-co/daily-js": "^0.90.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@pipecat-ai/client-js": "^1.12.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
"@xyflow/react": "^12.11.0",
@@ -477,6 +480,59 @@
"node": ">=22.14.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.71.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.71.0.tgz",

View File

@@ -10,6 +10,9 @@
},
"dependencies": {
"@daily-co/daily-js": "^0.90.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@pipecat-ai/client-js": "^1.12.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
"@xyflow/react": "^12.11.0",

View File

@@ -0,0 +1,10 @@
import { TestCasesPage } from "@/components/pages/TestCasesPage";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <TestCasesPage mode="detail" suiteId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { TestCasesPage } from "@/components/pages/TestCasesPage";
export default function Page() {
return <TestCasesPage mode="create" />;
}

View File

@@ -1,5 +1,5 @@
import { TestCasesPage } from "@/components/pages/TestCasesPage";
export default function Page() {
return <TestCasesPage />;
return <TestCasesPage mode="list" />;
}

View File

@@ -35,7 +35,8 @@ export type TestCaseKind =
| "user_simulation"
| "tool_call"
| "fixed_dialogue"
| "single_turn";
| "single_turn"
| "voice_run";
export type ChatScriptTurn = {
role: "user" | "assistant";
@@ -103,6 +104,7 @@ const KIND_LABEL: Record<TestCaseKind, string> = {
tool_call: "工具调用",
fixed_dialogue: "固定对话",
single_turn: "单轮响应",
voice_run: "语音运行",
};
const RECENT_IDS = ["tc-dual-car", "tc-hello"];

View File

@@ -25,12 +25,15 @@ export function SectionCard({
icon,
title,
description,
action,
children,
className,
}: {
icon?: ReactNode;
title?: string;
description?: string;
/** 标题行右侧操作区(如添加按钮) */
action?: ReactNode;
children: ReactNode;
className?: string;
}) {
@@ -52,12 +55,13 @@ export function SectionCard({
{icon}
</div>
)}
<div className="flex min-w-0 items-center gap-1.5">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<CardTitle className="text-sm font-medium leading-none">
{title}
</CardTitle>
{description && <HelpHint text={description} />}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
</CardHeader>
)}

View File

@@ -13,7 +13,6 @@ import {
Clock3,
Database,
HelpCircle,
Layers,
Wrench,
Home,
LogOut,
@@ -55,7 +54,6 @@ const monitorSubItems: NavItem[] = [
const testSubItems: NavItem[] = [
{ href: "/test/cases", label: "测试用例", icon: ClipboardList },
{ href: "/test/batch", label: "批量测试", icon: Layers },
];
export function Sidebar({ collapsed, onToggle }: SidebarProps) {

View File

@@ -1,10 +1,795 @@
import { PlaceholderPage } from "./PlaceholderPage";
"use client";
/**
* 测试用例管理:
* - 列表页Test Suite 一级容器
* - 详情页:左列表 + 右「单步回复」编辑器(只编辑,不运行)
*/
import {
ChevronLeft,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Rocket,
Save,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import {
AssistantIdentity,
EditableTitle,
EditorBackButton,
} from "@/components/assistant-editor/editor-controls";
import {
ListPageLayout,
ListPageSection,
} from "@/components/layout/list-page-layout";
import { TopbarPortal } from "@/components/layout/topbar-portal";
import {
NextReplyEditorBody,
type CaseEditorDraft,
} from "@/components/test-cases/next-reply-editor";
import { SuiteCaseList } from "@/components/test-cases/suite-case-list";
import { Button } from "@/components/ui/button";
import { DataList } from "@/components/ui/data-list";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { SearchInput } from "@/components/ui/search-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
createTestCase,
createTestSuite,
formatSuiteResult,
getTestSuite,
listTestCases,
listTestSuites,
removeTestCase,
removeTestCases,
removeTestSuite,
reorderTestCases,
suiteCaseStats,
SUPPORTED_TEST_CASE_KIND,
TEST_CASE_KIND_LABEL,
TEST_CASE_KIND_OPTIONS,
updateTestCase,
updateTestSuite,
type TestCase,
type TestCaseKind,
type TestSuite,
} from "@/data/test-suites";
import { assistantsApi, type Assistant } from "@/lib/api";
// 路由驱动:
// /test/cases → list
// /test/cases/new → create suite
// /test/cases/[id] → suite detail (cases)
export type TestCasesPageProps =
| { mode: "list" }
| { mode: "create" }
| { mode: "detail"; suiteId: string };
export function TestCasesPage(props: TestCasesPageProps) {
if (props.mode === "list") return <SuiteListView />;
if (props.mode === "create") return <SuiteCreateView />;
return <SuiteDetailView suiteId={props.suiteId} />;
}
// ─── Suite 列表 ──────────────────────────────────────────────────────────────
function SuiteListView() {
const router = useRouter();
const [suites, setSuites] = useState<TestSuite[]>([]);
const [search, setSearch] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
setSuites(listTestSuites());
}, []);
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
return suites.filter((suite) => {
if (!keyword) return true;
return [suite.name, suite.description, suite.assistantName, suite.id]
.join(" ")
.toLowerCase()
.includes(keyword);
});
}, [suites, search]);
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize;
const paginated = filtered.slice(pageStart, pageEnd);
function openSuite(suite: TestSuite) {
router.push(`/test/cases/${suite.id}`);
}
function removeSuite(suite: TestSuite) {
if (
!window.confirm(
`确定删除测试集“${suite.name}”及其全部测试用例吗?`,
)
) {
return;
}
setDeletingId(suite.id);
removeTestSuite(suite.id);
setSuites(listTestSuites());
setDeletingId(null);
}
export function TestCasesPage() {
return (
<PlaceholderPage
<ListPageLayout
title="测试用例"
description="管理助手的测试用例,覆盖典型问答与边界场景。"
/>
description="管理用于助手调试的单步回复测试场景。"
action={
<Button
className="w-full shrink-0 gap-2 sm:w-auto"
onClick={() => router.push("/test/cases/new")}
>
<Plus size={16} />
</Button>
}
>
<ListPageSection>
<ListToolbar
className="lg:justify-end"
search={
<SearchInput
value={search}
onChange={(value) => {
setSearch(value);
setCurrentPage(1);
}}
placeholder="搜索测试集..."
className="lg:w-[320px]"
/>
}
/>
<DataList<TestSuite>
rows={paginated}
rowKey={(suite) => suite.id}
onRowClick={openSuite}
empty={{
title: suites.length === 0 ? "暂无测试集" : "未找到匹配的测试集",
description:
suites.length === 0
? "点击右上角「新建测试集」开始。"
: "请调整关键词后再试。",
}}
pagination={{
page: safeCurrentPage,
totalPages,
onPageChange: setCurrentPage,
summary:
filtered.length === 0
? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filtered.length)} / 共 ${filtered.length} 个测试集`,
}}
columns={[
{
key: "name",
header: "测试集名称",
width: "md:w-[320px]",
cell: (suite) => (
<>
<div className="truncate font-medium text-foreground">
{suite.name}
</div>
<div className="mt-1 truncate text-xs text-muted-soft">
{suite.description || suite.id}
</div>
</>
),
},
{
key: "assistant",
header: "关联助手",
width: "md:w-[160px]",
cellClassName: "text-muted-foreground",
cell: (suite) => suite.assistantName || "—",
},
{
key: "caseCount",
header: "用例数",
width: "md:w-[96px]",
cellClassName: "tabular-nums text-muted-foreground",
cell: (suite) => suiteCaseStats(suite.id).total,
},
{
key: "lastResult",
header: "最近结果",
width: "md:w-[112px]",
cellClassName: "tabular-nums text-muted-foreground",
cell: (suite) => formatSuiteResult(suite.id),
},
{
key: "actions",
header: "操作",
align: "right",
cell: (suite) => (
<div
className="flex justify-end gap-2"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
openSuite(suite);
}}
>
<Pencil size={14} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${suite.name} 更多操作`}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
disabled={deletingId === suite.id}
onSelect={(event) => {
event.preventDefault();
window.setTimeout(() => removeSuite(suite), 0);
}}
>
{deletingId === suite.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
]}
/>
</ListPageSection>
</ListPageLayout>
);
}
// ─── 新建测试集 ──────────────────────────────────────────────────────────────
function SuiteCreateView() {
const router = useRouter();
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [loadingAssistants, setLoadingAssistants] = useState(true);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [assistantName, setAssistantName] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
void (async () => {
try {
const list = await assistantsApi.list();
setAssistants(list);
if (list[0]) setAssistantName(list[0].name);
} catch {
setAssistantName("视频快处助手");
} finally {
setLoadingAssistants(false);
}
})();
}, []);
function confirmCreate() {
if (!name.trim() || creating) return;
setCreating(true);
const saved = createTestSuite({
name,
description,
assistantName,
});
router.push(`/test/cases/${saved.id}`);
}
return (
<ListPageLayout
title="新建测试集"
className="max-w-[1180px]"
description="测试集是单步回复用例的业务分组。确认后进入用例编辑。"
action={
<Button
variant="outline"
className="w-full shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground sm:w-auto"
onClick={() => router.push("/test/cases")}
>
<ChevronLeft size={16} />
</Button>
}
>
<ListPageSection>
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Input
value={name}
autoFocus
onChange={(event) => setName(event.target.value)}
placeholder="例如:事故基础流程"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
</ListPageSection>
<ListPageSection>
<div className="space-y-5">
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground"></div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="用途说明(可选)"
rows={4}
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
<div>
<div className="mb-2 text-sm font-medium text-foreground">
</div>
{loadingAssistants ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : assistants.length > 0 ? (
<Select value={assistantName} onValueChange={setAssistantName}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择关联助手" />
</SelectTrigger>
<SelectContent>
{assistants.map((item) => (
<SelectItem key={item.id} value={item.name}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={assistantName}
onChange={(event) => setAssistantName(event.target.value)}
placeholder="助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
)}
</div>
</div>
</ListPageSection>
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={creating}
onClick={() => router.push("/test/cases")}
>
</Button>
<Button
className="gap-2"
disabled={!name.trim() || creating}
onClick={confirmCreate}
>
{creating ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Rocket size={16} />
)}
</Button>
</div>
</ListPageLayout>
);
}
// ─── Suite 详情:左列表 + 右编辑 ─────────────────────────────────────────────
function caseToDraft(item: TestCase): CaseEditorDraft {
return {
name: item.name,
kind: item.kind,
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
userInput: item.userInput,
assertionType: item.assertionType,
keywords: [...item.keywords],
keywordMatchMode: item.keywordMatchMode,
llmCriteria: item.llmCriteria,
};
}
function draftsEqual(a: CaseEditorDraft, b: CaseEditorDraft) {
return JSON.stringify(a) === JSON.stringify(b);
}
function SuiteDetailView({ suiteId }: { suiteId: string }) {
const router = useRouter();
const [suite, setSuite] = useState<TestSuite | null>(null);
const [cases, setCases] = useState<TestCase[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [draft, setDraft] = useState<CaseEditorDraft | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>(
null,
);
const [statusMessage, setStatusMessage] = useState("");
const [selectionMode, setSelectionMode] = useState(false);
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
function reload(preferId?: string | null) {
const nextSuite = getTestSuite(suiteId);
const nextCases = listTestCases(suiteId);
setSuite(nextSuite);
setCases(nextCases);
const nextSelected =
(preferId && nextCases.some((item) => item.id === preferId)
? preferId
: null) ??
(selectedId && nextCases.some((item) => item.id === selectedId)
? selectedId
: null) ??
nextCases[0]?.id ??
null;
setSelectedId(nextSelected);
if (nextSelected) {
const item = nextCases.find((caseItem) => caseItem.id === nextSelected);
if (item) {
const nextDraft = caseToDraft(item);
setDraft(nextDraft);
setSavedSnapshot(nextDraft);
}
} else {
setDraft(null);
setSavedSnapshot(null);
}
}
useEffect(() => {
setSelectionMode(false);
setCheckedIds(new Set());
reload();
// eslint-disable-next-line react-hooks/exhaustive-deps -- route-driven load
}, [suiteId]);
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
return cases.filter((item) => {
if (!keyword) return true;
return [item.name, item.description, TEST_CASE_KIND_LABEL[item.kind]]
.join(" ")
.toLowerCase()
.includes(keyword);
});
}, [cases, search]);
const dirty =
draft !== null &&
savedSnapshot !== null &&
!draftsEqual(draft, savedSnapshot);
function selectCase(item: TestCase) {
if (dirty && !window.confirm("当前用例有未保存修改,切换将丢弃。继续?")) {
return;
}
setSelectedId(item.id);
const nextDraft = caseToDraft(item);
setDraft(nextDraft);
setSavedSnapshot(nextDraft);
setStatusMessage("");
}
function handleCreateCase() {
if (dirty && !window.confirm("当前用例有未保存修改,新建将丢弃。继续?")) {
return;
}
const created = createTestCase({
suiteId,
name: "未命名用例",
});
if (!created) return;
reload(created.id);
setStatusMessage("");
}
function handleSave() {
if (!selectedId || !draft) return;
const saved = updateTestCase(selectedId, {
name: draft.name.trim() || "未命名用例",
kind: draft.kind,
contextTurns: draft.contextTurns,
userInput: draft.userInput,
assertionType: draft.assertionType,
keywords: draft.keywords,
keywordMatchMode: draft.keywordMatchMode,
llmCriteria: draft.llmCriteria,
});
if (!saved) return;
const nextDraft = caseToDraft(saved);
setCases(listTestCases(suiteId));
setSuite(getTestSuite(suiteId));
setDraft(nextDraft);
setSavedSnapshot(nextDraft);
setStatusMessage("已保存");
window.setTimeout(() => setStatusMessage(""), 2000);
}
function handleDeleteCase(item: TestCase) {
if (!window.confirm(`确定删除测试用例“${item.name}”吗?`)) return;
removeTestCase(item.id);
setCheckedIds((prev) => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
reload(selectedId === item.id ? null : selectedId);
}
function handleDeleteSelected() {
if (!selectedId || !draft) return;
if (
!window.confirm(
`确定删除测试用例“${draft.name.trim() || "未命名用例"}”吗?`,
)
) {
return;
}
removeTestCase(selectedId);
reload(null);
}
function handleEnterSelectionMode() {
setSelectionMode(true);
setCheckedIds(new Set());
}
function handleExitSelectionMode() {
setSelectionMode(false);
setCheckedIds(new Set());
}
function handleToggleChecked(id: string) {
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleToggleSelectAll() {
const allChecked =
filtered.length > 0 && filtered.every((item) => checkedIds.has(item.id));
if (allChecked) {
setCheckedIds((prev) => {
const next = new Set(prev);
for (const item of filtered) next.delete(item.id);
return next;
});
return;
}
setCheckedIds((prev) => {
const next = new Set(prev);
for (const item of filtered) next.add(item.id);
return next;
});
}
function handleBulkDelete() {
const ids = filtered
.filter((item) => checkedIds.has(item.id))
.map((item) => item.id);
if (ids.length === 0) return;
if (!window.confirm(`确定删除选中的 ${ids.length} 个测试用例吗?`)) return;
removeTestCases(ids);
handleExitSelectionMode();
reload(selectedId && ids.includes(selectedId) ? null : selectedId);
}
function handleReorder(orderedIds: string[]) {
reorderTestCases(suiteId, orderedIds);
setCases(listTestCases(suiteId));
}
function renameSuite(nextName: string) {
if (!suite || nextName === suite.name) return;
const saved = updateTestSuite(suite.id, { name: nextName });
if (saved) setSuite(saved);
}
if (!suite) {
return (
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-4 py-16">
<div className="font-medium text-destructive"></div>
<Button
variant="outline"
size="sm"
className="w-fit border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => router.push("/test/cases")}
>
</Button>
</div>
);
}
return (
<>
<TopbarPortal>
<div className="flex h-full min-w-0 flex-1 items-center gap-2 sm:-ml-2 lg:-ml-4">
<EditorBackButton
ariaLabel="返回测试集列表"
onClick={() => router.push("/test/cases")}
/>
<EditableTitle
value={suite.name}
onChange={renameSuite}
placeholder="未命名测试集"
editLabel="测试集名称"
/>
<AssistantIdentity assistantId={suite.id} />
</div>
</TopbarPortal>
<div
data-app-content="full-bleed"
className="flex h-full min-h-0 flex-col overflow-hidden bg-background lg:flex-row"
>
<SuiteCaseList
cases={cases}
filtered={filtered}
selectedId={selectedId}
search={search}
selectionMode={selectionMode}
checkedIds={checkedIds}
onSearchChange={setSearch}
onSelectCase={selectCase}
onCreateCase={handleCreateCase}
onDeleteCase={handleDeleteCase}
onEnterSelectionMode={handleEnterSelectionMode}
onExitSelectionMode={handleExitSelectionMode}
onToggleChecked={handleToggleChecked}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDelete}
onReorder={handleReorder}
/>
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
{!draft ? (
<div className="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">
</div>
) : (
<>
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-hairline px-4 py-3 sm:gap-3 sm:px-6">
<EditableTitle
value={draft.name}
onChange={(value) =>
setDraft({ ...draft, name: value || "未命名用例" })
}
placeholder="未命名用例"
editLabel="用例名称"
variant="panel"
allowEmpty
/>
<Select
value={draft.kind}
onValueChange={(value) =>
setDraft({
...draft,
kind: value as TestCaseKind,
})
}
>
<SelectTrigger className="h-9 w-[132px] shrink-0 border-hairline-strong bg-background text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TEST_CASE_KIND_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex shrink-0 items-center gap-2">
{dirty ? (
<span className="text-xs text-amber-600"></span>
) : statusMessage ? (
<span className="text-xs text-muted-foreground">
{statusMessage}
</span>
) : null}
<Button
size="sm"
className="gap-1.5"
disabled={!dirty || !draft.name.trim()}
onClick={handleSave}
>
<Save size={14} />
</Button>
<Button
variant="outline"
size="sm"
className="border-hairline-strong text-muted-foreground hover:text-destructive"
onClick={handleDeleteSelected}
>
<Trash2 size={14} />
</Button>
</div>
</div>
{draft.kind === SUPPORTED_TEST_CASE_KIND ? (
<NextReplyEditorBody draft={draft} onChange={setDraft} />
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<div className="text-sm font-medium text-foreground">
{TEST_CASE_KIND_LABEL[draft.kind]} ·
</div>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
</p>
</div>
)}
</>
)}
</main>
</div>
</>
);
}

View File

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

View File

@@ -0,0 +1,382 @@
"use client";
/**
* 测试集左侧用例列表:
* - 普通模式点击打开编辑hover 显示拖拽手柄排序
* - 选择模式checkbox + 顶部批量操作栏;禁用拖拽
*/
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CheckSquare, GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SearchInput } from "@/components/ui/search-input";
import {
TEST_CASE_KIND_LABEL,
type TestCase,
} from "@/data/test-suites";
import { cn } from "@/lib/utils";
export function SuiteCaseList({
cases,
filtered,
selectedId,
search,
selectionMode,
checkedIds,
onSearchChange,
onSelectCase,
onCreateCase,
onDeleteCase,
onEnterSelectionMode,
onExitSelectionMode,
onToggleChecked,
onToggleSelectAll,
onBulkDelete,
onReorder,
}: {
cases: TestCase[];
filtered: TestCase[];
selectedId: string | null;
search: string;
selectionMode: boolean;
checkedIds: Set<string>;
onSearchChange: (value: string) => void;
onSelectCase: (item: TestCase) => void;
onCreateCase: () => void;
onDeleteCase: (item: TestCase) => void;
onEnterSelectionMode: () => void;
onExitSelectionMode: () => void;
onToggleChecked: (id: string) => void;
onToggleSelectAll: () => void;
onBulkDelete: () => void;
onReorder: (orderedIds: string[]) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const allFilteredChecked =
filtered.length > 0 && filtered.every((item) => checkedIds.has(item.id));
const checkedCount = filtered.filter((item) => checkedIds.has(item.id)).length;
const dragEnabled = !selectionMode;
function handleDragEnd(event: DragEndEvent) {
if (!dragEnabled) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const filteredIds = filtered.map((item) => item.id);
const oldIndex = filteredIds.indexOf(String(active.id));
const newIndex = filteredIds.indexOf(String(over.id));
if (oldIndex < 0 || newIndex < 0) return;
const nextFilteredIds = arrayMove(filteredIds, oldIndex, newIndex);
const filteredSet = new Set(filteredIds);
let cursor = 0;
// 筛选视图内重排:未出现在筛选结果中的用例保持相对位置
const nextFullOrder = cases.map((item) => {
if (!filteredSet.has(item.id)) return item.id;
return nextFilteredIds[cursor++];
});
onReorder(nextFullOrder);
}
return (
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-hairline bg-background lg:w-[32%] lg:border-b-0 lg:border-r">
{selectionMode ? (
<div className="flex shrink-0 items-center gap-2 border-b border-hairline px-3 py-2.5 sm:px-4">
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
className="size-3.5 accent-primary"
checked={allFilteredChecked}
ref={(element) => {
if (!element) return;
element.indeterminate =
checkedCount > 0 && checkedCount < filtered.length;
}}
onChange={onToggleSelectAll}
aria-label="全选当前列表"
/>
</label>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{checkedCount}
</span>
<Button
variant="outline"
size="sm"
className="h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={checkedCount === 0}
onClick={onBulkDelete}
>
</Button>
<Button
variant="outline"
size="sm"
className="h-8 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={onExitSelectionMode}
>
</Button>
</div>
) : (
<div className="flex shrink-0 items-center justify-between gap-3 px-4 py-3 sm:px-5">
<p className="min-w-0 truncate text-sm text-muted-foreground">
{cases.length}
</p>
<div className="flex shrink-0 items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={cases.length === 0}
onClick={onEnterSelectionMode}
>
<CheckSquare size={14} />
</Button>
<Button
size="sm"
className="h-8 shrink-0 gap-1.5"
onClick={onCreateCase}
>
<Plus size={14} />
</Button>
</div>
</div>
)}
<div className="shrink-0 px-4 pb-3 sm:px-5">
<SearchInput
value={search}
onChange={onSearchChange}
placeholder="搜索测试用例"
/>
</div>
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto px-3 pb-4 sm:px-4">
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-10 text-center">
<div className="text-sm font-medium text-foreground">
{cases.length === 0 ? "暂无测试用例" : "未找到匹配用例"}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{cases.length === 0
? "点击右上角「新建」开始。"
: "请调整关键词后再试。"}
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filtered.map((item) => item.id)}
strategy={verticalListSortingStrategy}
disabled={!dragEnabled}
>
<ul className="space-y-2">
{filtered.map((item) => (
<SortableCaseCard
key={item.id}
item={item}
active={item.id === selectedId}
selectionMode={selectionMode}
checked={checkedIds.has(item.id)}
dragEnabled={dragEnabled}
onSelect={() => onSelectCase(item)}
onToggleChecked={() => onToggleChecked(item.id)}
onDelete={() => onDeleteCase(item)}
/>
))}
</ul>
</SortableContext>
</DndContext>
)}
</div>
</aside>
);
}
function SortableCaseCard({
item,
active,
selectionMode,
checked,
dragEnabled,
onSelect,
onToggleChecked,
onDelete,
}: {
item: TestCase;
active: boolean;
selectionMode: boolean;
checked: boolean;
dragEnabled: boolean;
onSelect: () => void;
onToggleChecked: () => void;
onDelete: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id, disabled: !dragEnabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li ref={setNodeRef} style={style} className={cn(isDragging && "z-10")}>
<div
role="button"
tabIndex={0}
onClick={() => {
if (selectionMode) {
onToggleChecked();
return;
}
onSelect();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (selectionMode) onToggleChecked();
else onSelect();
}
}}
className={cn(
"group w-full cursor-pointer rounded-2xl border px-3 py-3 text-left transition-colors",
active && !selectionMode
? "border-hairline-strong bg-surface-strong"
: "border-hairline bg-card hover:bg-canvas-soft/80",
checked && selectionMode && "border-hairline-strong bg-surface-strong/70",
isDragging && "shadow-md ring-1 ring-hairline-strong",
)}
>
<div className="flex items-start gap-1.5">
{selectionMode ? (
<input
type="checkbox"
className="mt-1 size-3.5 shrink-0 accent-primary"
checked={checked}
onChange={onToggleChecked}
onClick={(event) => event.stopPropagation()}
aria-label={`选择 ${item.name}`}
/>
) : (
<button
type="button"
className={cn(
"mt-0.5 flex h-6 w-5 shrink-0 cursor-grab items-center justify-center rounded text-muted-soft opacity-0 transition-opacity active:cursor-grabbing group-hover:opacity-100",
isDragging && "opacity-100",
)}
aria-label={`拖拽排序 ${item.name}`}
onClick={(event) => event.stopPropagation()}
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</button>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{item.name}
</span>
<span className="inline-flex h-5 shrink-0 items-center rounded-full border border-hairline bg-background px-2 text-[11px] text-muted-foreground">
{TEST_CASE_KIND_LABEL[item.kind]}
</span>
</div>
{!selectionMode && (
<div
className="flex shrink-0 items-center"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="h-7 w-7 text-muted-soft hover:text-foreground"
aria-label={`${item.name} 更多操作`}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem
className="rounded-lg"
onSelect={onSelect}
>
<Pencil size={14} />
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
onSelect={(event) => {
event.preventDefault();
window.setTimeout(onDelete, 0);
}}
>
<Trash2 size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-soft">
{item.description || "暂无说明"}
</p>
</div>
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,457 @@
/**
* 测试集 / 测试用例 — 管理页用的本地 mock。
* 两层Test Suite → Test Case。
* 第一版只支持「单步回复 / Next Reply Test」只编辑不运行。
*/
export type TestCaseKind =
| "next_reply"
| "tool_call"
| "fixed_dialogue"
| "user_simulation"
| "voice_run";
export type TestCaseResult = "pass" | "fail" | "not_run";
export type AssertionType = "keyword" | "llm";
export type KeywordMatchMode = "any" | "all";
export type ContextTurn = {
role: "agent" | "user";
content: string;
};
export type TestSuite = {
id: string;
name: string;
description: string;
/** 关联助手展示名MVP 直接存文案,接 API 后可改成 assistantId */
assistantName: string;
updatedAt: string;
};
export type TestCase = {
id: string;
suiteId: string;
name: string;
description: string;
kind: TestCaseKind;
lastResult: TestCaseResult;
/** 对话上下文(通常是 Agent 上一轮) */
contextTurns: ContextTurn[];
/** 当前用户输入 */
userInput: string;
assertionType: AssertionType;
keywords: string[];
keywordMatchMode: KeywordMatchMode;
/** LLM 判断时的判断标准 */
llmCriteria: string;
/** 套件内排序,越小越靠前 */
sortOrder: number;
updatedAt: string;
};
/** 第一版仅「单步回复」可编辑,其余类型显示开发中 */
export const SUPPORTED_TEST_CASE_KIND: TestCaseKind = "next_reply";
export const TEST_CASE_KIND_LABEL: Record<TestCaseKind, string> = {
next_reply: "单步回复",
tool_call: "工具调用",
fixed_dialogue: "固定对话",
user_simulation: "用户模拟",
voice_run: "语音运行",
};
export const TEST_CASE_KIND_OPTIONS: { value: TestCaseKind; label: string }[] = [
{ value: "next_reply", label: "单步回复" },
{ value: "tool_call", label: "工具调用" },
{ value: "fixed_dialogue", label: "固定对话" },
{ value: "user_simulation", label: "用户模拟" },
{ value: "voice_run", label: "语音运行" },
];
export const ASSERTION_TYPE_LABEL: Record<AssertionType, string> = {
keyword: "关键词",
llm: "LLM 判断",
};
const INITIAL_SUITES: TestSuite[] = [
{
id: "suite_001",
name: "事故基础流程",
description: "核心业务流程和正常事故处理",
assistantName: "视频快处助手",
updatedAt: "2026-08-05T10:20:00+08:00",
},
{
id: "suite_002",
name: "异常输入与澄清",
description: "模糊表达、误打断、主动唤醒等",
assistantName: "视频快处助手",
updatedAt: "2026-08-04T16:40:00+08:00",
},
{
id: "suite_003",
name: "实时语音交互",
description: "打断、延迟、VAD、语音链路等",
assistantName: "视频快处助手",
updatedAt: "2026-08-03T09:15:00+08:00",
},
];
const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
{
id: "tc_001",
suiteId: "suite_001",
name: "正常双车事故开场",
description: "开场后用户补充事故经过",
kind: "next_reply",
lastResult: "pass",
contextTurns: [],
userInput: "我这里刚刚撞了一下。",
assertionType: "keyword",
keywords: ["经过", "描述", "受伤"],
keywordMatchMode: "any",
llmCriteria: "",
updatedAt: "2026-08-05T10:18:00+08:00",
},
{
id: "tc_002",
suiteId: "suite_001",
name: "有人伤转人工",
description: "用户提到人伤时应引导转人工",
kind: "next_reply",
lastResult: "pass",
contextTurns: [],
userInput: "有人受伤了,流血不止。",
assertionType: "llm",
keywords: [],
keywordMatchMode: "any",
llmCriteria:
"Agent 应识别人员受伤风险,明确告知将转接人工,并避免继续推进普通快处流程。",
updatedAt: "2026-08-05T10:12:00+08:00",
},
{
id: "tc_101",
suiteId: "suite_002",
name: "用户说“喂”",
description: "验证主动唤醒回复且业务状态不推进",
kind: "next_reply",
lastResult: "pass",
contextTurns: [],
userInput: "喂",
assertionType: "keyword",
keywords: ["我在", "请说", "继续"],
keywordMatchMode: "any",
llmCriteria: "",
updatedAt: "2026-08-04T16:35:00+08:00",
},
{
id: "tc_102",
suiteId: "suite_002",
name: "用户只说“嗯”",
description: "短促确认不应误推进流程",
kind: "next_reply",
lastResult: "fail",
contextTurns: [],
userInput: "嗯",
assertionType: "llm",
keywords: [],
keywordMatchMode: "any",
llmCriteria:
"Agent 应当回应用户的主动唤醒,同时继续引导用户描述事故情况,不应该无响应。",
updatedAt: "2026-08-04T16:20:00+08:00",
},
{
id: "tc_103",
suiteId: "suite_002",
name: "模糊事故描述",
description: "地点含糊时应主动澄清",
kind: "next_reply",
lastResult: "not_run",
contextTurns: [],
userInput: "就在那边……撞了一下。",
assertionType: "keyword",
keywords: ["哪里", "路口", "路名", "再说"],
keywordMatchMode: "any",
llmCriteria: "",
updatedAt: "2026-08-04T15:30:00+08:00",
},
{
id: "tc_201",
suiteId: "suite_003",
name: "用户打断播报",
description: "播报中打断后正确切换聆听并承接",
kind: "next_reply",
lastResult: "pass",
contextTurns: [],
userInput: "等一下,对方走了。",
assertionType: "llm",
keywords: [],
keywordMatchMode: "any",
llmCriteria:
"Agent 应立即停止原播报思路,确认已听到用户新信息,并围绕「对方离开」继续询问。",
updatedAt: "2026-08-03T09:10:00+08:00",
},
];
/** 按套件出现顺序写入 sortOrder */
const INITIAL_CASES: TestCase[] = (() => {
const counters = new Map<string, number>();
return RAW_CASES.map((item) => {
const order = counters.get(item.suiteId) ?? 0;
counters.set(item.suiteId, order + 1);
return { ...item, sortOrder: order };
});
})();
/** 会话内可变的 mock 仓库(刷新页面会重置) */
let suites = [...INITIAL_SUITES];
let cases = structuredClone(INITIAL_CASES);
let suiteSeq = 4;
let caseSeq = 300;
function nowIso() {
return new Date().toISOString();
}
function nextSuiteId() {
const id = `suite_${String(suiteSeq).padStart(3, "0")}`;
suiteSeq += 1;
return id;
}
function nextCaseId() {
const id = `tc_${String(caseSeq).padStart(3, "0")}`;
caseSeq += 1;
return id;
}
export function listTestSuites(): TestSuite[] {
return [...suites].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
}
export function getTestSuite(id: string): TestSuite | null {
return suites.find((item) => item.id === id) ?? null;
}
export function getTestCase(id: string): TestCase | null {
return cases.find((item) => item.id === id) ?? null;
}
export function listTestCases(suiteId: string): TestCase[] {
return cases
.filter((item) => item.suiteId === suiteId)
.sort(
(a, b) =>
a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, "zh-CN"),
);
}
export function suiteCaseStats(suiteId: string): {
total: number;
passed: number;
run: number;
} {
const items = cases.filter((item) => item.suiteId === suiteId);
const run = items.filter((item) => item.lastResult !== "not_run");
const passed = run.filter((item) => item.lastResult === "pass");
return { total: items.length, passed: passed.length, run: run.length };
}
export function createTestSuite(input: {
name: string;
description: string;
assistantName: string;
}): TestSuite {
const suite: TestSuite = {
id: nextSuiteId(),
name: input.name.trim(),
description: input.description.trim(),
assistantName: input.assistantName.trim() || "未关联助手",
updatedAt: nowIso(),
};
suites = [suite, ...suites];
return suite;
}
export function updateTestSuite(
id: string,
patch: Partial<Pick<TestSuite, "name" | "description" | "assistantName">>,
): TestSuite | null {
const index = suites.findIndex((item) => item.id === id);
if (index < 0) return null;
const next = {
...suites[index],
...patch,
updatedAt: nowIso(),
};
suites = [...suites.slice(0, index), next, ...suites.slice(index + 1)];
return next;
}
export function removeTestSuite(id: string): boolean {
const before = suites.length;
suites = suites.filter((item) => item.id !== id);
cases = cases.filter((item) => item.suiteId !== id);
return suites.length < before;
}
export function createTestCase(input: {
suiteId: string;
name?: string;
description?: string;
}): TestCase | null {
if (!getTestSuite(input.suiteId)) return null;
const maxOrder = cases
.filter((item) => item.suiteId === input.suiteId)
.reduce((max, item) => Math.max(max, item.sortOrder), -1);
const item: TestCase = {
id: nextCaseId(),
suiteId: input.suiteId,
name: (input.name ?? "未命名用例").trim() || "未命名用例",
description: (input.description ?? "").trim(),
kind: "next_reply",
lastResult: "not_run",
contextTurns: [],
userInput: "",
assertionType: "keyword",
keywords: [],
keywordMatchMode: "any",
llmCriteria: "",
sortOrder: maxOrder + 1,
updatedAt: nowIso(),
};
cases = [...cases, item];
updateTestSuite(input.suiteId, {});
return item;
}
export type TestCasePatch = Partial<
Pick<
TestCase,
| "name"
| "description"
| "kind"
| "contextTurns"
| "userInput"
| "assertionType"
| "keywords"
| "keywordMatchMode"
| "llmCriteria"
| "lastResult"
>
>;
export function updateTestCase(
id: string,
patch: TestCasePatch,
): TestCase | null {
const index = cases.findIndex((item) => item.id === id);
if (index < 0) return null;
const next = {
...cases[index],
...patch,
updatedAt: nowIso(),
};
cases = [...cases.slice(0, index), next, ...cases.slice(index + 1)];
updateTestSuite(next.suiteId, {});
return next;
}
export function removeTestCase(id: string): boolean {
const existing = cases.find((item) => item.id === id);
if (!existing) return false;
cases = cases.filter((item) => item.id !== id);
renumberSortOrder(existing.suiteId);
updateTestSuite(existing.suiteId, {});
return true;
}
/** 批量删除;返回受影响的 suiteId若有 */
export function removeTestCases(ids: string[]): string | null {
const idSet = new Set(ids);
const affected = cases.find((item) => idSet.has(item.id));
if (!affected) return null;
const suiteId = affected.suiteId;
cases = cases.filter((item) => !idSet.has(item.id));
renumberSortOrder(suiteId);
updateTestSuite(suiteId, {});
return suiteId;
}
/**
* 按给定 id 顺序写回 sortOrder应包含该套件全部用例 id
* 用于拖拽结束后持久化顺序。
*/
export function reorderTestCases(suiteId: string, orderedIds: string[]): void {
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
cases = cases.map((item) => {
if (item.suiteId !== suiteId) return item;
const nextOrder = orderMap.get(item.id);
if (nextOrder === undefined) return item;
return { ...item, sortOrder: nextOrder };
});
updateTestSuite(suiteId, {});
}
function renumberSortOrder(suiteId: string) {
const ordered = cases
.filter((item) => item.suiteId === suiteId)
.sort((a, b) => a.sortOrder - b.sortOrder);
const orderMap = new Map(ordered.map((item, index) => [item.id, index]));
cases = cases.map((item) => {
if (item.suiteId !== suiteId) return item;
const nextOrder = orderMap.get(item.id);
return nextOrder === undefined ? item : { ...item, sortOrder: nextOrder };
});
}
export function formatSuiteResult(suiteId: string): string {
const { passed, run, total } = suiteCaseStats(suiteId);
if (total === 0) return "—";
if (run === 0) return "未运行";
return `${passed}/${total}`;
}
export function formatCaseResult(result: TestCaseResult): {
label: string;
className: string;
dotClassName: string | null;
} {
if (result === "pass") {
return {
label: "通过",
className: "text-emerald-600",
dotClassName: "bg-emerald-500",
};
}
if (result === "fail") {
return {
label: "失败",
className: "text-destructive",
dotClassName: "bg-destructive",
};
}
return {
label: "未运行",
className: "text-muted-soft",
dotClassName: null,
};
}
export function formatUpdatedAt(value?: string | null) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}