From ba6c7c6be3e10d11489e67a1c1af5932e8198468 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sun, 9 Aug 2026 17:08:53 +0800 Subject: [PATCH] Expand test case fixed-input editor and batch case detail split view. Unify multi-turn behaviors (reply/tool), overall expectation, and input-mode picker while keeping non-text modes as coming soon; batch completed/running use a half-and-half detail panel below the top bar. Co-authored-by: Cursor --- .../batch-test/batch-case-detail-drawer.tsx | 139 +- .../batch-test/batch-run-completed.tsx | 234 +-- .../batch-test/batch-run-running.tsx | 212 +-- .../components/layout/list-page-layout.tsx | 12 +- .../src/components/pages/BatchTestPage.tsx | 6 +- .../src/components/pages/TestCasesPage.tsx | 124 +- .../test-cases/input-mode-picker.tsx | 185 ++ .../test-cases/next-reply-editor.tsx | 1554 ++++++++++++++--- .../components/test-cases/suite-case-list.tsx | 4 +- frontend/src/components/ui/sheet.tsx | 4 +- frontend/src/data/test-suites.ts | 576 +++++- 11 files changed, 2363 insertions(+), 687 deletions(-) create mode 100644 frontend/src/components/test-cases/input-mode-picker.tsx diff --git a/frontend/src/components/batch-test/batch-case-detail-drawer.tsx b/frontend/src/components/batch-test/batch-case-detail-drawer.tsx index b78efb9..91a1fee 100644 --- a/frontend/src/components/batch-test/batch-case-detail-drawer.tsx +++ b/frontend/src/components/batch-test/batch-case-detail-drawer.tsx @@ -1,7 +1,8 @@ "use client"; /** - * 批量测试用例详情抽屉。 + * 批量测试用例详情面板。 + * 与 prompt mode DebugDrawer overlay 相同:作为右侧 flex 半屏,无遮罩/模糊。 * 当前展示前端 mock 数据,后续可直接替换为真实执行事件与校验结果。 */ @@ -12,21 +13,15 @@ import { MessageSquareText, Target, TriangleAlert, + X, } from "lucide-react"; import { BatchCaseStatusBadge } from "@/components/batch-test/batch-case-status"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; import type { BatchRunCase } from "@/data/batch-run"; import { cn } from "@/lib/utils"; type BatchCaseDetailDrawerProps = { - item: BatchRunCase | null; + item: BatchRunCase; assistantName: string; onClose: () => void; }; @@ -37,80 +32,72 @@ export function BatchCaseDetailDrawer({ onClose, }: BatchCaseDetailDrawerProps) { return ( - { - if (!open) onClose(); - }} - > - - {item && ( - <> - -

- 用例详情 -

- - {item.name} - - - 被测助手:{assistantName} · 当前为前端 Mock 结果 - -
+ ); } diff --git a/frontend/src/components/batch-test/batch-run-completed.tsx b/frontend/src/components/batch-test/batch-run-completed.tsx index d1cb3bd..0121956 100644 --- a/frontend/src/components/batch-test/batch-run-completed.tsx +++ b/frontend/src/components/batch-test/batch-run-completed.tsx @@ -23,6 +23,7 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) { const [selectedCaseId, setSelectedCaseId] = useState(null); const selectedCase = run.cases.find((item) => item.id === selectedCaseId) ?? null; + const detailOpen = Boolean(selectedCase); const counts = countByStatus(run.cases); const judged = counts.pass + counts.fail; @@ -31,129 +32,140 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) { const failRate = judged === 0 ? 0 : 100 - passRate; return ( - <> -
- {/* 结果总览 */} -
-
-

{run.title}

- -
- {run.finishedAt && ( -

- 完成时间:{formatRunTime(run.finishedAt)} - {run.stopped ? " · 已手动停止" : ""} -

+
+
+
-
-
- {counts.pass} / {judged || total} 通过 -
-
- 通过率 {passRate}% -
+ > + {/* 结果总览 */} +
+
+

+ {run.title} +

+
+ {run.finishedAt && ( +

+ 完成时间:{formatRunTime(run.finishedAt)} + {run.stopped ? " · 已手动停止" : ""} +

+ )} - +
+
+
+ {counts.pass} / {judged || total} 通过 +
+
+ 通过率 {passRate}% +
+
-
- - - {counts.skipped > 0 && ( + + +
- )} + + {counts.skipped > 0 && ( + + )} +
-
-
+
- {/* 用例列表 */} -
-
-

- 用例列表(共 {total} 个) -

-
+ {/* 用例列表 */} +
+
+

+ 用例列表(共 {total} 个) +

+
-
- - - - - - - - - - - {run.cases.map((item, index) => { - return ( - - + + ); + })} + +
#测试用例状态详情
- +
+
+
+ - setSelectedCaseId(null)} - /> - + {selectedCase && ( + setSelectedCaseId(null)} + /> + )} + ); } diff --git a/frontend/src/components/batch-test/batch-run-running.tsx b/frontend/src/components/batch-test/batch-run-running.tsx index c34134a..15a058a 100644 --- a/frontend/src/components/batch-test/batch-run-running.tsx +++ b/frontend/src/components/batch-test/batch-run-running.tsx @@ -22,118 +22,128 @@ export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) { const [selectedCaseId, setSelectedCaseId] = useState(null); const selectedCase = run.cases.find((item) => item.id === selectedCaseId) ?? null; + const detailOpen = Boolean(selectedCase); const counts = countByStatus(run.cases); const done = counts.pass + counts.fail + counts.skipped; const total = run.cases.length; const percent = total === 0 ? 0 : Math.round((done / total) * 100); return ( - <> -
- {/* 进度总览 */} -
-
-
-
-

- {run.title} -

- -
- -
-
-
+
+
+
+ {/* 进度总览 */} +
+
+
+
+

+ {run.title} +

+
-
- - {done} / {total} - {" "} - 已完成 - {percent}% + +
+
+
+
+
+ + {done} / {total} + {" "} + 已完成 + {percent}% +
+ +
+ + + + +
+
+
+ + {/* 用例列表 */} +
+
+

+ 测试用例(共 {total} 个) +

-
- - - - -
-
-
- - {/* 用例列表 */} -
-
-

- 测试用例(共 {total} 个) -

-
- -
- - - - - - - - - - - {run.cases.map((item, index) => { - return ( - - + + ); + })} + +
#测试用例状态详情
- +
+
+
+
- setSelectedCaseId(null)} - /> - + {selectedCase && ( + setSelectedCaseId(null)} + /> + )} + ); } @@ -149,14 +159,14 @@ function Stat({ const toneClass = { success: "text-success", destructive: "text-destructive", - primary: "text-primary", + primary: "text-foreground", muted: "text-muted-foreground", }[tone]; return ( -
+
{label}
-
+
{value}
diff --git a/frontend/src/components/layout/list-page-layout.tsx b/frontend/src/components/layout/list-page-layout.tsx index cefb551..a734a57 100644 --- a/frontend/src/components/layout/list-page-layout.tsx +++ b/frontend/src/components/layout/list-page-layout.tsx @@ -13,6 +13,8 @@ export function ListPageLayout({ topbarAction, children, className, + /** full-bleed:占满 top bar 下方区域,供左右分屏等布局使用 */ + contentMode = "list", }: { title: string; description?: ReactNode; @@ -21,8 +23,10 @@ export function ListPageLayout({ topbarAction?: ReactNode; children: ReactNode; className?: string; + contentMode?: "list" | "full-bleed"; }) { const hasHeader = Boolean(description || action); + const isFullBleed = contentMode === "full-bleed"; return ( <> @@ -31,13 +35,15 @@ export function ListPageLayout({
- {hasHeader && ( + {hasHeader && !isFullBleed && (
- {draft.kind === SUPPORTED_TEST_CASE_KIND ? ( + {draft.inputMode === "fixed_script_text" ? ( ) : (
- {TEST_CASE_KIND_LABEL[draft.kind]} · 开发中 + {TEST_CASE_INPUT_MODE_LABEL[draft.inputMode]} · 开发中

- 当前仅支持「单步回复」编辑器,其它类型稍后接入。 + 当前仅支持「固定脚本 · 文字」编辑器,其它输入模式稍后接入。

)} diff --git a/frontend/src/components/test-cases/input-mode-picker.tsx b/frontend/src/components/test-cases/input-mode-picker.tsx new file mode 100644 index 0000000..6d631a0 --- /dev/null +++ b/frontend/src/components/test-cases/input-mode-picker.tsx @@ -0,0 +1,185 @@ +"use client"; + +/** + * 测试用例顶部紧凑模式选择器:固定脚本 / 用户模拟。 + */ + +import { + AudioLines, + Check, + ChevronDown, + MessageSquareText, + Mic, + Type, + Volume2, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + TEST_CASE_INPUT_MODE_LABEL, + type TestCaseInputMode, +} from "@/data/test-suites"; +import { cn } from "@/lib/utils"; + +const FIXED_SCRIPT_OPTIONS: { + value: TestCaseInputMode; + title: string; + description: string; + icon: typeof Type; + available: boolean; +}[] = [ + { + value: "fixed_script_text", + title: "文字", + description: "使用固定文字逐轮测试", + icon: Type, + available: true, + }, + { + value: "fixed_script_turn_voice", + title: "逐轮语音", + description: "固定文字自动合成为语音", + icon: Volume2, + available: false, + }, + { + value: "fixed_script_continuous_voice", + title: "连续语音", + description: "按指定时序连续发送语音", + icon: AudioLines, + available: false, + }, +]; + +const USER_SIM_OPTIONS: { + value: TestCaseInputMode; + title: string; + description: string; + icon: typeof MessageSquareText; + available: boolean; +}[] = [ + { + value: "user_sim_text", + title: "文字", + description: "使用模型模拟文字用户", + icon: MessageSquareText, + available: false, + }, + { + value: "user_sim_voice", + title: "语音", + description: "使用模型模拟语音用户", + icon: Mic, + available: false, + }, +]; + +export function InputModePicker({ + value, + onChange, +}: { + value: TestCaseInputMode; + onChange: (mode: TestCaseInputMode) => void; +}) { + return ( + + + + + + + 固定脚本 + + {FIXED_SCRIPT_OPTIONS.map((option) => ( + onChange(option.value)} + /> + ))} + + + + + 用户模拟 + + {USER_SIM_OPTIONS.map((option) => ( + onChange(option.value)} + /> + ))} + + + ); +} + +function ModeMenuItem({ + selected, + title, + description, + icon: Icon, + available, + onSelect, +}: { + selected: boolean; + title: string; + description: string; + icon: typeof Type; + available: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/frontend/src/components/test-cases/next-reply-editor.tsx b/frontend/src/components/test-cases/next-reply-editor.tsx index e1e1e68..c9a06e6 100644 --- a/frontend/src/components/test-cases/next-reply-editor.tsx +++ b/frontend/src/components/test-cases/next-reply-editor.tsx @@ -1,25 +1,50 @@ "use client"; /** - * 单步回复 / Next Reply Test 表单主体。 - * 带与 prompt mode 相同的锚点导航 + 滚动高亮;标题栏由外层负责。 + * 测试用例编辑器主体: + * 对话上下文 →(固定脚本轮次 / 用户模拟配置)→ 整体行为预期。 + * 五种输入模式复用同一套布局,仅中间区域按模式增减字段。 */ 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 { + GripVertical, MessageSquareText, MessagesSquare, Plus, - Sparkles, Target, Trash2, + Volume2, + Wrench, X, } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, 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 { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Select, SelectContent, @@ -30,53 +55,203 @@ import { import { Textarea } from "@/components/ui/textarea"; import { ASSERTION_TYPE_LABEL, + KEYWORD_MATCH_MODE_LABEL, + OVERALL_EXPECTATION_CRITERIA_MAX, + TOOL_PARAM_MATCH_MODE_LABEL, + TURN_SEND_TIMING_LABEL, + VOICE_OPTIONS, + VOICE_SPEED_OPTIONS, + createEmptyFixedInputTurn, + createReplyBehavior, + createToolCallBehavior, + isFixedScriptMode, type AssertionType, type ContextTurn, + type ExpectedBehavior, + type FixedInputTurn, type KeywordMatchMode, + type OverallExpectation, + type ReplyExpectedBehavior, + type TestCaseInputMode, type TestCaseKind, + type ToolCallExpectedBehavior, + type ToolParamAssertion, + type ToolParamMatchMode, + type TurnSendTiming, + type UserSimulationConfig, + type VoiceSettings, } from "@/data/test-suites"; +import { toolsApi, type Tool } from "@/lib/api"; import { cn } from "@/lib/utils"; export type CaseEditorDraft = { name: string; + inputMode: TestCaseInputMode; kind: TestCaseKind; contextTurns: ContextTurn[]; - userInput: string; - assertionType: AssertionType; - keywords: string[]; - keywordMatchMode: KeywordMatchMode; - llmCriteria: string; + turns: FixedInputTurn[]; + voiceSettings: VoiceSettings | null; + userSimulation: UserSimulationConfig | null; + overallExpectation: OverallExpectation | null; }; -type NextReplySectionId = "context" | "user_input" | "expectation"; - -const NEXT_REPLY_SECTIONS: { - id: NextReplySectionId; +/** 编辑器内使用的工具选项(来自 Agent 工具库) */ +export type EditorToolOption = { + id: string; + functionName: string; label: string; -}[] = [ - { id: "context", label: "对话上下文" }, - { id: "user_input", label: "当前用户输入" }, - { id: "expectation", label: "结果预期" }, + parameters: { name: string; description: string }[]; +}; + +type EditorSectionId = "context" | "main" | "overall"; + +function editorSections(mode: TestCaseInputMode): { + id: EditorSectionId; + label: string; +}[] { + return [ + { id: "context", label: "对话上下文" }, + { + id: "main", + label: isFixedScriptMode(mode) ? "固定输入" : "用户模拟", + }, + { id: "overall", label: "整体行为预期" }, + ]; +} + +/** API 不可用时的兜底工具,保证下拉可选 */ +const FALLBACK_TOOLS: EditorToolOption[] = [ + { + id: "mock_transfer_to_human", + functionName: "transfer_to_human", + label: "转人工", + parameters: [ + { name: "reason", description: "转人工原因" }, + { name: "target", description: "转接目标" }, + ], + }, + { + id: "mock_end_conversation", + functionName: "end_conversation", + label: "结束会话", + parameters: [{ name: "reason", description: "结束原因" }], + }, ]; -/** 单步回复表单主体(含锚点导航) */ +function toolParametersFromDefinition(tool: Tool): EditorToolOption["parameters"] { + const definition = tool.definition; + if (definition.type === "http" || definition.type === "client") { + return definition.config.parameters.map((param) => ({ + name: param.name, + description: param.description, + })); + } + if (definition.type === "system") { + return definition.config.captureReason + ? [{ name: "reason", description: "调用原因" }] + : []; + } + if (definition.type === "mcp") { + const schema = definition.config.inputSchema; + const properties = + schema && + typeof schema === "object" && + "properties" in schema && + schema.properties && + typeof schema.properties === "object" + ? (schema.properties as Record) + : {}; + return Object.entries(properties).map(([name, value]) => ({ + name, + description: value?.description ?? "", + })); + } + return []; +} + +function toEditorToolOption(tool: Tool): EditorToolOption { + return { + id: tool.id, + functionName: tool.functionName || tool.name, + label: tool.name || tool.functionName, + parameters: toolParametersFromDefinition(tool), + }; +} + +/** 固定输入表单主体(含锚点导航) */ export function NextReplyEditorBody({ draft, onChange, + availableTools, }: { draft: CaseEditorDraft; onChange: (next: CaseEditorDraft) => void; + /** 可选:由外层传入 Agent 已配置工具;缺省时编辑器自行拉取工具库 */ + availableTools?: EditorToolOption[]; }) { - const [keywordDraft, setKeywordDraft] = useState(""); const scrollContainerRef = useRef(null); - const sectionRefs = useRef>({ + const sectionRefs = useRef>({ context: null, - user_input: null, - expectation: null, + main: null, + overall: null, }); - const selectedAnchorRef = useRef(null); - const [activeSection, setActiveSection] = - useState("context"); + const selectedAnchorRef = useRef(null); + const [activeSection, setActiveSection] = useState("main"); + const [loadedTools, setLoadedTools] = useState([]); + const [expandedBehaviorIds, setExpandedBehaviorIds] = useState>( + () => new Set(), + ); + const sections = editorSections(draft.inputMode); + const fixedScript = isFixedScriptMode(draft.inputMode); + const showVoiceSettings = + draft.inputMode === "fixed_script_turn_voice" || + draft.inputMode === "fixed_script_continuous_voice" || + draft.inputMode === "user_sim_voice"; + const showTurnVoiceHint = draft.inputMode === "fixed_script_turn_voice"; + const showContinuousTiming = + draft.inputMode === "fixed_script_continuous_voice"; + const voiceSettings = draft.voiceSettings ?? { + voiceId: "zh_female", + speed: 1.0, + }; + const userSimulation = draft.userSimulation ?? { + role: "", + goal: "", + knownFacts: "", + behaviorNotes: "", + maxTurns: 10, + }; + + const tools = useMemo(() => { + if (availableTools && availableTools.length > 0) return availableTools; + if (loadedTools.length > 0) return loadedTools; + return FALLBACK_TOOLS; + }, [availableTools, loadedTools]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + useEffect(() => { + if (availableTools && availableTools.length > 0) return; + let cancelled = false; + void (async () => { + try { + const list = await toolsApi.list(); + if (cancelled) return; + const mapped = list.map(toEditorToolOption); + setLoadedTools(mapped.length > 0 ? mapped : FALLBACK_TOOLS); + } catch { + if (!cancelled) setLoadedTools(FALLBACK_TOOLS); + } + })(); + return () => { + cancelled = true; + }; + }, [availableTools]); useEffect(() => { const container = scrollContainerRef.current; @@ -93,12 +268,13 @@ export function NextReplyEditorBody({ const containerTop = scrollContainer.getBoundingClientRect().top; const activationLine = containerTop + 24; - let nextSection: NextReplySectionId = NEXT_REPLY_SECTIONS[0].id; + const sectionIds: EditorSectionId[] = ["context", "main", "overall"]; + let nextSection: EditorSectionId = sectionIds[0]; - for (const section of NEXT_REPLY_SECTIONS) { - const element = sectionRefs.current[section.id]; + for (const sectionId of sectionIds) { + const element = sectionRefs.current[sectionId]; if (element && element.getBoundingClientRect().top <= activationLine) { - nextSection = section.id; + nextSection = sectionId; } } @@ -109,7 +285,7 @@ export function NextReplyEditorBody({ scrollContainer.clientHeight < 8; if (reachedBottom) { - nextSection = NEXT_REPLY_SECTIONS[NEXT_REPLY_SECTIONS.length - 1].id; + nextSection = sectionIds[sectionIds.length - 1]; } setActiveSection((current) => @@ -148,7 +324,7 @@ export function NextReplyEditorBody({ }; }, []); - function scrollToSection(sectionId: NextReplySectionId) { + function scrollToSection(sectionId: EditorSectionId) { const container = scrollContainerRef.current; const section = sectionRefs.current[sectionId]; if (!container || !section) return; @@ -189,24 +365,80 @@ export function NextReplyEditorBody({ }); } - 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 updateTurns(turns: FixedInputTurn[]) { + onChange({ ...draft, turns }); } - function removeKeyword(index: number) { - onChange({ - ...draft, - keywords: draft.keywords.filter((_, itemIndex) => itemIndex !== index), + function updateTurn(turnId: string, patch: Partial) { + updateTurns( + draft.turns.map((turn) => + turn.id === turnId ? { ...turn, ...patch } : turn, + ), + ); + } + + function setTurnBehaviors(turnId: string, behaviors: ExpectedBehavior[]) { + updateTurn(turnId, { behaviors }); + } + + function addBehavior(turnId: string, behavior: ExpectedBehavior) { + const turn = draft.turns.find((item) => item.id === turnId); + if (!turn) return; + setTurnBehaviors(turnId, [...turn.behaviors, behavior]); + setExpandedBehaviorIds((current) => new Set(current).add(behavior.id)); + } + + function updateBehavior( + turnId: string, + behaviorId: string, + next: ExpectedBehavior, + ) { + const turn = draft.turns.find((item) => item.id === turnId); + if (!turn) return; + setTurnBehaviors( + turnId, + turn.behaviors.map((item) => (item.id === behaviorId ? next : item)), + ); + } + + function removeBehavior(turnId: string, behaviorId: string) { + const turn = draft.turns.find((item) => item.id === turnId); + if (!turn) return; + setTurnBehaviors( + turnId, + turn.behaviors.filter((item) => item.id !== behaviorId), + ); + setExpandedBehaviorIds((current) => { + const next = new Set(current); + next.delete(behaviorId); + return next; + }); + } + + function addTurn() { + updateTurns([...draft.turns, createEmptyFixedInputTurn()]); + } + + function removeTurn(turnId: string) { + if (draft.turns.length <= 1) return; + updateTurns(draft.turns.filter((turn) => turn.id !== turnId)); + } + + function handleTurnDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = draft.turns.findIndex((turn) => turn.id === active.id); + const newIndex = draft.turns.findIndex((turn) => turn.id === over.id); + if (oldIndex < 0 || newIndex < 0) return; + updateTurns(arrayMove(draft.turns, oldIndex, newIndex)); + } + + function toggleBehaviorExpanded(behaviorId: string) { + setExpandedBehaviorIds((current) => { + const next = new Set(current); + if (next.has(behaviorId)) next.delete(behaviorId); + else next.add(behaviorId); + return next; }); } @@ -214,11 +446,9 @@ export function NextReplyEditorBody({
- scrollToSection(sectionId as NextReplySectionId) - } + onSelect={(sectionId) => scrollToSection(sectionId as EditorSectionId)} className="bg-background px-4 pt-3 sm:px-6 sm:pt-4 lg:px-8" contentClassName="mx-auto w-full max-w-3xl" /> @@ -228,7 +458,6 @@ export function NextReplyEditorBody({ 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" >
- {/* 对话上下文 */}
{ sectionRefs.current.context = element; @@ -319,235 +548,139 @@ export function NextReplyEditorBody({
- {/* 当前用户输入 */}
{ - sectionRefs.current.user_input = element; + sectionRefs.current.main = element; }} className="scroll-mt-3 space-y-3" > - } - title="当前用户输入" - description="本轮要测的用户原话" - > -
- - User - - - onChange({ ...draft, userInput: event.target.value }) + {fixedScript ? ( + } + title="固定输入" + description="每轮只填用户输入;可添加回复要求或工具调用等预期行为(AND)。" + > + {showVoiceSettings && ( + + onChange({ ...draft, voiceSettings: next }) + } + /> + )} + + + turn.id)} + strategy={verticalListSortingStrategy} + > +
+ {draft.turns.map((turn, index) => ( + 1} + expandedBehaviorIds={expandedBehaviorIds} + onToggleBehavior={toggleBehaviorExpanded} + onChangeUserInput={(userInput) => + updateTurn(turn.id, { userInput }) + } + onChangeSendTiming={(sendTiming) => + updateTurn(turn.id, { sendTiming }) + } + onChangeSendDelayMs={(sendDelayMs) => + updateTurn(turn.id, { sendDelayMs }) + } + onAddReply={() => + addBehavior(turn.id, createReplyBehavior("llm")) + } + onAddToolCall={() => + addBehavior(turn.id, createToolCallBehavior()) + } + onUpdateBehavior={(behavior) => + updateBehavior(turn.id, behavior.id, behavior) + } + onRemoveBehavior={(behaviorId) => + removeBehavior(turn.id, behaviorId) + } + onRemove={ + draft.turns.length > 1 + ? () => removeTurn(turn.id) + : undefined + } + /> + ))} +
+
+
+ +
+ +
+
+ ) : ( + } + title="用户模拟" + description="用角色与目标驱动模拟用户;不再预写固定轮次。" + > + {showVoiceSettings && ( + + onChange({ ...draft, voiceSettings: next }) + } + /> + )} + + onChange({ ...draft, userSimulation: next }) } - placeholder="喂" - className="border-hairline-strong bg-background" /> -
-
+ + )}
- {/* 结果预期 */}
{ - sectionRefs.current.expectation = element; + sectionRefs.current.overall = element; }} className="scroll-mt-3 space-y-3" > } - title="结果预期" - description="用关键词或 LLM 标准判断 Agent 下一句回复是否合格" + title="整体行为预期" + description={ + fixedScript + ? "可选:判断整段对话结束后是否达到业务目标(区别于每轮预期行为)" + : "可选:判断整段模拟对话结束后是否达到业务目标" + } > -
- {( - [ - { - 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 ( - - ); - })} -
- - {draft.assertionType === "keyword" ? ( -
-
-
- 关键词 -
-
- {draft.keywords.map((keyword, index) => ( - - {keyword} - - - ))} - - 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" - /> -
-

- 回车或逗号添加;期望 Agent 回复中出现这些词。 -

-
- -
-
- 匹配方式 -
-
- {( - [ - { - value: "any" as const, - label: "任一匹配", - hint: "命中其中一个即可", - }, - { - value: "all" as const, - label: "全部匹配", - hint: "需要全部出现", - }, - ] as const - ).map((option) => { - const active = - draft.keywordMatchMode === option.value; - return ( - - ); - })} -
-
-
- ) : ( -
-
- 判断标准 -
-