"use client"; /** * 测试用例编辑器主体: * 对话上下文 → 固定文字脚本轮次 → 整体评估标准。 */ 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 { ChevronDown, ChevronUp, GripVertical, MessageSquareText, MessagesSquare, Plus, Target, Trash2, Wrench, X, } from "lucide-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, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { ASSERTION_TYPE_LABEL, KEYWORD_MATCH_MODE_LABEL, OVERALL_CRITERION_TEXT_MAX, TOOL_PARAM_MATCH_MODE_LABEL, createEmptyFixedInputTurn, createOverallCriterion, createReplyBehavior, createToolCallBehavior, getExpectedBehaviorValidationMessage, type AssertionType, type ContextRole, type ContextTurn, type ExpectedBehavior, type FixedInputTurn, type KeywordMatchMode, type OverallCriterion, type ReplyExpectedBehavior, type TestCaseInputMode, type ToolCallExpectedBehavior, type ToolParamAssertion, type ToolParamMatchMode, } from "@/data/test-suites"; import { toolsApi, type Tool } from "@/lib/api"; import { cn } from "@/lib/utils"; export type CaseEditorDraft = { name: string; inputMode: TestCaseInputMode; contextTurns: ContextTurn[]; turns: FixedInputTurn[]; overallCriteria: OverallCriterion[]; }; const CONTEXT_ROLE_LABEL: Record = { agent: "Agent", user: "User", tool_call: "Tool Call", tool_result: "Tool Result", }; function contextPlaceholder(role: ContextRole): string { if (role === "agent") return "Agent 已经说过的话…"; if (role === "user") return "用户已经说过的话…"; if (role === "tool_call") return '{\n "reason": "存在人员受伤"\n}'; return '{\n "status": "accepted"\n}'; } /** 编辑器内使用的工具选项(来自 Agent 工具库) */ export type EditorToolOption = { id: string; functionName: string; label: string; parameters: { name: string; description: string }[]; }; type EditorSectionId = "context" | "main" | "overall"; function editorSections(): { id: EditorSectionId; label: string; }[] { return [ { id: "context", label: "对话上下文" }, { id: "main", label: "固定输入" }, { 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 scrollContainerRef = useRef(null); const sectionRefs = useRef>({ context: null, main: null, overall: null, }); const selectedAnchorRef = useRef(null); const [activeSection, setActiveSection] = useState("main"); const [loadedTools, setLoadedTools] = useState([]); const [contextAddOpen, setContextAddOpen] = useState(false); const [expandedBehaviorIds, setExpandedBehaviorIds] = useState>( () => new Set(), ); const sections = editorSections(); 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; 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; const sectionIds: EditorSectionId[] = ["context", "main", "overall"]; let nextSection: EditorSectionId = sectionIds[0]; for (const sectionId of sectionIds) { const element = sectionRefs.current[sectionId]; if (element && element.getBoundingClientRect().top <= activationLine) { nextSection = sectionId; } } const reachedBottom = scrollContainer.scrollHeight > scrollContainer.clientHeight + 8 && scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 8; if (reachedBottom) { nextSection = sectionIds[sectionIds.length - 1]; } 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: EditorSectionId) { 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) { onChange({ ...draft, contextTurns: draft.contextTurns.map((turn, turnIndex) => turnIndex === index ? { ...turn, ...patch } : turn, ), }); } function addContextTurn(role: ContextRole) { onChange({ ...draft, contextTurns: [ ...draft.contextTurns, { role, content: role === "tool_call" || role === "tool_result" ? "{\n \n}" : "", ...(role === "tool_call" || role === "tool_result" ? { toolName: "", toolCallId: "" } : {}), }, ], }); setContextAddOpen(false); } function removeContextTurn(index: number) { onChange({ ...draft, contextTurns: draft.contextTurns.filter( (_, turnIndex) => turnIndex !== index, ), }); } function updateTurns(turns: FixedInputTurn[]) { onChange({ ...draft, turns }); } 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; }); } return (
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" />
{ sectionRefs.current.context = element; }} className="scroll-mt-3 space-y-3" > } title="对话上下文" description="可选:运行前注入 pipeline 的历史消息与工具调用链" action={ {(Object.keys(CONTEXT_ROLE_LABEL) as ContextRole[]).map( (role) => ( ), )} } > {draft.contextTurns.length === 0 ? (

默认无上下文;可添加对话消息或工具调用记录

) : (
{draft.contextTurns.map((turn, index) => (
第 {index + 1} 条
{(turn.role === "tool_call" || turn.role === "tool_result") && (
updateContext(index, { toolName: event.target.value }) } placeholder="工具名称,例如 transfer_to_human" aria-label="工具名称" className="h-8 border-hairline-strong bg-background font-mono text-xs" /> updateContext(index, { toolCallId: event.target.value, }) } placeholder="Call ID(可选)" aria-label="工具调用 ID" className="h-8 border-hairline-strong bg-background font-mono text-xs" /> {turn.role === "tool_result" && ( )}
)}