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