Files
ai-video-fullstack/frontend/src/components/test-cases/next-reply-editor.tsx
T

1694 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<ContextRole, string> = {
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<string, { description?: string }>)
: {};
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<HTMLDivElement>(null);
const sectionRefs = useRef<Record<EditorSectionId, HTMLElement | null>>({
context: null,
main: null,
overall: null,
});
const selectedAnchorRef = useRef<EditorSectionId | null>(null);
const [activeSection, setActiveSection] = useState<EditorSectionId>("main");
const [loadedTools, setLoadedTools] = useState<EditorToolOption[]>([]);
const [contextAddOpen, setContextAddOpen] = useState(false);
const [expandedBehaviorIds, setExpandedBehaviorIds] = useState<Set<string>>(
() => 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<ContextTurn>) {
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<FixedInputTurn>) {
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 (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SectionAnchorTabs
ariaLabel="测试用例分区"
sections={sections}
activeSectionId={activeSection}
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"
/>
<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="可选:运行前注入 pipeline 的历史消息与工具调用链"
action={
<Popover open={contextAddOpen} onOpenChange={setContextAddOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 border-hairline-strong text-xs text-muted-foreground"
>
<Plus size={13} />
添加消息
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-60 p-1.5">
{(Object.keys(CONTEXT_ROLE_LABEL) as ContextRole[]).map(
(role) => (
<button
key={role}
type="button"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-left text-sm text-foreground transition-colors hover:bg-surface-strong"
onClick={() => addContextTurn(role)}
>
{CONTEXT_ROLE_LABEL[role]}
</button>
),
)}
</PopoverContent>
</Popover>
}
>
{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">
默认无上下文;可添加对话消息或工具调用记录
</p>
) : (
<div className="space-y-2">
{draft.contextTurns.map((turn, index) => (
<div
key={index}
className="space-y-2 rounded-xl border border-hairline bg-canvas-soft/30 p-2.5"
>
<div className="flex items-center gap-2">
<Select
value={turn.role}
onValueChange={(value) =>
updateContext(index, {
role: value as ContextRole,
content:
value === "tool_call" || value === "tool_result"
? "{\n \n}"
: "",
toolName: "",
toolCallId: "",
})
}
>
<SelectTrigger
aria-label={`上下文第 ${index + 1} 条消息类型`}
className="h-8 w-[122px] shrink-0 border-hairline-strong bg-background text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CONTEXT_ROLE_LABEL) as ContextRole[]).map(
(role) => (
<SelectItem key={role} value={role}>
{CONTEXT_ROLE_LABEL[role]}
</SelectItem>
),
)}
</SelectContent>
</Select>
<span className="min-w-0 flex-1 text-[11px] text-muted-soft">
第 {index + 1} 条
</span>
<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>
{(turn.role === "tool_call" ||
turn.role === "tool_result") && (
<div
className={cn(
"grid gap-2",
turn.role === "tool_result"
? "sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_112px]"
: "sm:grid-cols-2",
)}
>
<Input
value={turn.toolName ?? ""}
onChange={(event) =>
updateContext(index, { toolName: event.target.value })
}
placeholder="工具名称,例如 transfer_to_human"
aria-label="工具名称"
className="h-8 border-hairline-strong bg-background font-mono text-xs"
/>
<Input
value={turn.toolCallId ?? ""}
onChange={(event) =>
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" && (
<Select
value={turn.isError ? "error" : "success"}
onValueChange={(value) =>
updateContext(index, {
isError: value === "error",
})
}
>
<SelectTrigger
aria-label="工具返回状态"
className="h-8 border-hairline-strong bg-background text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">错误</SelectItem>
</SelectContent>
</Select>
)}
</div>
)}
<Textarea
value={turn.content}
onChange={(event) =>
updateContext(index, { content: event.target.value })
}
rows={
turn.role === "tool_call" ||
turn.role === "tool_result"
? 4
: 2
}
placeholder={contextPlaceholder(turn.role)}
className={cn(
"field-sizing-fixed resize-y border-hairline-strong bg-background text-sm",
(turn.role === "tool_call" ||
turn.role === "tool_result") && "font-mono text-xs",
)}
/>
</div>
))}
</div>
)}
</SectionCard>
</section>
<section
ref={(element) => {
sectionRefs.current.main = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<MessageSquareText size={15} />}
title="固定输入"
description="每轮只填用户输入;可添加回复要求或工具调用等预期行为(AND)。"
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleTurnDragEnd}
>
<SortableContext
items={draft.turns.map((turn) => turn.id)}
strategy={verticalListSortingStrategy}
>
<div className="divide-y divide-hairline">
{draft.turns.map((turn, index) => (
<SortableFixedInputTurn
key={turn.id}
turn={turn}
index={index}
tools={tools}
canDelete={draft.turns.length > 1}
expandedBehaviorIds={expandedBehaviorIds}
onToggleBehavior={toggleBehaviorExpanded}
onChangeUserInput={(userInput) =>
updateTurn(turn.id, { userInput })
}
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
}
/>
))}
</div>
</SortableContext>
</DndContext>
<div className="flex justify-center border-t border-hairline pt-3">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 rounded-full border-hairline-strong text-xs text-muted-foreground"
onClick={addTurn}
>
<Plus size={14} />
添加下一轮
</Button>
</div>
</SectionCard>
</section>
<section
ref={(element) => {
sectionRefs.current.overall = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<Target size={15} />}
title="整体评估标准"
description="可添加多条独立标准;全部通过时,用例才算通过"
>
<OverallCriteriaFields
value={draft.overallCriteria}
onChange={(overallCriteria) =>
onChange({ ...draft, overallCriteria })
}
/>
</SectionCard>
</section>
</div>
</div>
</div>
);
}
function OverallCriteriaFields({
value,
onChange,
}: {
value: OverallCriterion[];
onChange: (value: OverallCriterion[]) => void;
}) {
function updateCriterion(id: string, patch: Partial<OverallCriterion>) {
onChange(
value.map((criterion) =>
criterion.id === id ? { ...criterion, ...patch } : criterion,
),
);
}
return (
<div className="space-y-3">
{value.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-6 text-center">
<p className="text-xs text-muted-soft">暂未配置整段对话评估</p>
</div>
) : (
<div className="space-y-2.5">
{value.map((criterion, index) => (
<div
key={criterion.id}
className="space-y-2.5 rounded-xl border border-hairline bg-canvas-soft/30 p-3"
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground">
标准 {index + 1}
</span>
<span className="rounded-full border border-hairline px-2 py-0.5 text-[10px] text-muted-soft">
LLM 判断
</span>
<span className="min-w-0 flex-1" />
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-7 w-7 text-muted-soft hover:text-destructive"
onClick={() =>
onChange(value.filter((item) => item.id !== criterion.id))
}
aria-label={`删除整体评估标准 ${index + 1}`}
>
<Trash2 size={13} />
</Button>
</div>
<Input
value={criterion.name}
onChange={(event) =>
updateCriterion(criterion.id, { name: event.target.value })
}
placeholder="标准名称,例如:正确处理人伤场景"
aria-label={`整体评估标准 ${index + 1} 名称`}
className="h-8 border-hairline-strong bg-background text-xs"
/>
<Textarea
value={criterion.criteria}
onChange={(event) =>
updateCriterion(criterion.id, {
criteria: event.target.value.slice(
0,
OVERALL_CRITERION_TEXT_MAX,
),
})
}
rows={4}
placeholder="描述整段对话结束后应达到的业务目标,以及不应出现的行为…"
className="field-sizing-fixed min-h-[96px] resize-y border-hairline-strong bg-background text-sm"
/>
<div className="flex justify-end text-[11px] tabular-nums text-muted-soft">
{criterion.criteria.length} / {OVERALL_CRITERION_TEXT_MAX}
</div>
</div>
))}
</div>
)}
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] leading-4 text-muted-soft">
运行结果会逐条展示;当前通过规则固定为 AND。
</p>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1.5 rounded-full border-hairline-strong text-xs text-muted-foreground"
onClick={() =>
onChange([
...value,
createOverallCriterion(`评估标准 ${value.length + 1}`),
])
}
>
<Plus size={13} />
添加标准
</Button>
</div>
</div>
);
}
function SortableFixedInputTurn({
turn,
index,
tools,
canDelete,
expandedBehaviorIds,
onToggleBehavior,
onChangeUserInput,
onAddReply,
onAddToolCall,
onUpdateBehavior,
onRemoveBehavior,
onRemove,
}: {
turn: FixedInputTurn;
index: number;
tools: EditorToolOption[];
canDelete: boolean;
expandedBehaviorIds: Set<string>;
onToggleBehavior: (behaviorId: string) => void;
onChangeUserInput: (value: string) => void;
onAddReply: () => void;
onAddToolCall: () => void;
onUpdateBehavior: (behavior: ExpectedBehavior) => void;
onRemoveBehavior: (behaviorId: string) => void;
onRemove?: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: turn.id });
const [addOpen, setAddOpen] = useState(false);
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative py-3.5 first:pt-0 last:pb-1",
isDragging && "z-10 rounded-xl bg-card shadow-md ring-1 ring-hairline",
)}
>
<div className="mb-2.5 flex items-center gap-1">
<button
type="button"
className={cn(
"flex h-7 w-6 shrink-0 cursor-grab items-center justify-center rounded text-muted-soft opacity-40 transition-opacity active:cursor-grabbing group-hover:opacity-100",
isDragging && "opacity-100",
)}
aria-label={`拖拽调整第 ${index + 1} 轮顺序`}
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</button>
<div className="min-w-0 flex-1 text-sm font-medium text-foreground">
第 {index + 1} 轮
</div>
{canDelete && onRemove && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-7 w-7 shrink-0 text-muted-soft opacity-40 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={onRemove}
aria-label={`删除第 ${index + 1} 轮`}
>
<Trash2 size={14} />
</Button>
)}
</div>
<div className="space-y-3 pl-1">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
用户输入
</div>
<Textarea
value={turn.userInput}
onChange={(event) => onChangeUserInput(event.target.value)}
placeholder="输入本轮用户原话…"
rows={2}
className="field-sizing-fixed min-h-[56px] resize-y border-hairline-strong bg-background text-sm"
/>
</div>
<div className="space-y-2">
{turn.behaviors.length > 0 && (
<div className="text-xs font-medium text-muted-foreground">
预期行为
</div>
)}
<div className="space-y-2">
{turn.behaviors.map((behavior) => (
<ExpectedBehaviorCard
key={behavior.id}
behavior={behavior}
tools={tools}
expanded={expandedBehaviorIds.has(behavior.id)}
onToggle={() => onToggleBehavior(behavior.id)}
onChange={onUpdateBehavior}
onRemove={() => onRemoveBehavior(behavior.id)}
/>
))}
</div>
<Popover open={addOpen} onOpenChange={setAddOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex h-7 items-center gap-1 text-xs text-muted-soft transition-colors hover:text-foreground"
>
<Plus size={13} />
添加预期行为
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-1.5">
<div className="px-2.5 py-1.5 text-[11px] font-medium tracking-wide text-muted-soft uppercase">
添加预期行为
</div>
<button
type="button"
className="flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-surface-strong"
onClick={() => {
onAddReply();
setAddOpen(false);
}}
>
<MessageSquareText
size={15}
className="mt-0.5 shrink-0 text-muted-foreground"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
回复要求
</span>
<span className="mt-0.5 block text-xs leading-4 text-muted-soft">
验证 Agent 回复的内容或语义
</span>
</span>
</button>
<button
type="button"
className="flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-surface-strong"
onClick={() => {
onAddToolCall();
setAddOpen(false);
}}
>
<Wrench
size={15}
className="mt-0.5 shrink-0 text-violet-400/90"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
工具调用
</span>
<span className="mt-0.5 block text-xs leading-4 text-muted-soft">
验证 Agent 是否调用指定工具及参数
</span>
</span>
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
);
}
function ExpectedBehaviorCard({
behavior,
tools,
expanded,
onToggle,
onChange,
onRemove,
}: {
behavior: ExpectedBehavior;
tools: EditorToolOption[];
expanded: boolean;
onToggle: () => void;
onChange: (behavior: ExpectedBehavior) => void;
onRemove: () => void;
}) {
const validationMessage = getExpectedBehaviorValidationMessage(behavior);
if (!expanded) {
return (
<button
type="button"
onClick={onToggle}
aria-expanded={false}
title="展开配置"
className={cn(
"group/card w-full rounded-xl border px-3 py-2.5 text-left transition-colors hover:bg-canvas-soft/60",
behavior.type === "tool_call"
? "border-violet-500/25 bg-violet-500/[0.04]"
: "border-hairline bg-canvas-soft/30",
validationMessage && "border-destructive/40",
)}
>
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<BehaviorSummary behavior={behavior} />
{validationMessage && (
<p className="mt-1.5 pl-[18px] text-[11px] text-destructive">
{validationMessage}
</p>
)}
</div>
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-soft transition-colors group-hover/card:text-foreground">
展开配置
<ChevronDown size={14} />
</span>
</div>
</button>
);
}
return (
<div
className={cn(
"rounded-xl border px-3 py-2.5",
behavior.type === "tool_call"
? "border-violet-500/30 bg-violet-500/[0.04]"
: "border-hairline bg-canvas-soft/40",
validationMessage && "border-destructive/40",
)}
>
<div className="mb-2.5 flex items-center gap-2">
<button
type="button"
onClick={onToggle}
aria-expanded={true}
title="收起配置"
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
>
{behavior.type === "reply" ? (
<MessageSquareText size={14} className="shrink-0 text-muted-foreground" />
) : (
<Wrench size={14} className="shrink-0 text-violet-400/90" />
)}
<span className="text-xs font-medium text-foreground">
{behavior.type === "reply" ? "回复要求" : "工具调用"}
</span>
<span className="ml-auto inline-flex items-center gap-1 text-[11px] font-normal text-muted-soft">
收起
<ChevronUp size={14} />
</span>
</button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-7 w-7 shrink-0 text-muted-soft hover:text-destructive"
onClick={onRemove}
aria-label="删除预期行为"
>
<Trash2 size={13} />
</Button>
</div>
{behavior.type === "reply" ? (
<ReplyBehaviorEditor
behavior={behavior}
onChange={onChange}
/>
) : (
<ToolCallBehaviorEditor
behavior={behavior}
tools={tools}
onChange={onChange}
/>
)}
{validationMessage && (
<p className="mt-2 text-[11px] text-destructive">
{validationMessage}
</p>
)}
</div>
);
}
function BehaviorSummary({ behavior }: { behavior: ExpectedBehavior }) {
if (behavior.type === "reply") {
const modeLabel = ASSERTION_TYPE_LABEL[behavior.assertionType];
const detail =
behavior.assertionType === "keyword"
? behavior.keywords.length > 0
? behavior.keywords.join("、")
: "未配置关键词"
: behavior.llmCriteria.trim() || "未填写判断标准";
return (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<MessageSquareText size={13} className="text-muted-foreground" />
回复要求 · {modeLabel}
{behavior.assertionType === "keyword" && behavior.negateKeywords
? " · 不应包含"
: ""}
</div>
<p className="line-clamp-2 pl-[18px] text-xs leading-4 text-muted-soft">
{detail}
</p>
</div>
);
}
const name = behavior.functionName || "未选择工具";
const params = behavior.paramAssertions;
return (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<Wrench size={13} className="text-violet-400/90" />
{behavior.expectation === "not_called" ? "禁止调用 · " : ""}
{name}
</div>
{behavior.expectation === "not_called" ? (
<p className="pl-[18px] text-xs text-muted-soft">调用次数必须为 0</p>
) : params.length === 0 ? (
<p className="pl-[18px] text-xs text-muted-soft">
调用 {behavior.minCalls}
{behavior.maxCalls === null
? " 次以上"
: behavior.minCalls === behavior.maxCalls
? " 次"
: `–${behavior.maxCalls} 次`}
,不校验参数
</p>
) : (
<div className="space-y-0.5 pl-[18px]">
{params.map((param) => (
<p key={param.name} className="text-xs text-muted-soft">
{param.name} · {TOOL_PARAM_MATCH_MODE_LABEL[param.matchMode]}
{param.value.trim() ? ` · ${param.value}` : ""}
</p>
))}
</div>
)}
</div>
);
}
function ReplyBehaviorEditor({
behavior,
onChange,
}: {
behavior: ReplyExpectedBehavior;
onChange: (behavior: ReplyExpectedBehavior) => void;
}) {
const [keywordDraft, setKeywordDraft] = useState("");
function addKeyword(raw: string) {
const value = raw.trim();
if (!value) return;
if (behavior.keywords.includes(value)) {
setKeywordDraft("");
return;
}
onChange({ ...behavior, keywords: [...behavior.keywords, value] });
setKeywordDraft("");
}
function removeKeyword(index: number) {
onChange({
...behavior,
keywords: behavior.keywords.filter((_, itemIndex) => itemIndex !== index),
});
}
return (
<div className="space-y-2.5">
<div className="grid gap-2 sm:grid-cols-[88px_minmax(0,1fr)] sm:items-center">
<div className="text-xs text-muted-soft">判断方式</div>
<Select
value={behavior.assertionType}
onValueChange={(value) =>
onChange({
...behavior,
assertionType: value as AssertionType,
})
}
>
<SelectTrigger className="h-8 border-hairline-strong bg-background text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="keyword">
{ASSERTION_TYPE_LABEL.keyword}
</SelectItem>
<SelectItem value="llm">{ASSERTION_TYPE_LABEL.llm}</SelectItem>
</SelectContent>
</Select>
</div>
{behavior.assertionType === "keyword" ? (
<>
<div className="grid gap-2 sm:grid-cols-[88px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs text-muted-soft">关键词</div>
<div className="space-y-1.5">
<div className="flex min-h-9 flex-wrap items-center gap-1.5 rounded-xl border border-hairline-strong bg-background px-2 py-1.5">
{behavior.keywords.map((keyword, keywordIndex) => (
<span
key={`${keyword}-${keywordIndex}`}
className="inline-flex h-6 items-center gap-1 rounded-full bg-surface-strong px-2 text-[11px] text-foreground"
>
{keyword}
<button
type="button"
className="text-muted-soft hover:text-destructive"
onClick={() => removeKeyword(keywordIndex)}
aria-label={`删除关键词 ${keyword}`}
>
<X size={11} />
</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 &&
behavior.keywords.length > 0
) {
removeKeyword(behavior.keywords.length - 1);
}
}}
placeholder={
behavior.keywords.length === 0 ? "输入关键词" : ""
}
className="h-6 min-w-[96px] flex-1 border-0 bg-transparent px-1 text-xs shadow-none focus-visible:ring-0"
/>
</div>
<button
type="button"
onClick={() => addKeyword(keywordDraft)}
className="inline-flex h-6 items-center gap-1 text-[11px] text-muted-soft transition-colors hover:text-foreground"
>
<Plus size={12} />
添加
</button>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[88px_minmax(0,1fr)] sm:items-center">
<div className="text-xs text-muted-soft">关键词要求</div>
<div className="grid gap-2 sm:grid-cols-2">
<Select
value={behavior.negateKeywords ? "not_contain" : "contain"}
onValueChange={(value) =>
onChange({
...behavior,
negateKeywords: value === "not_contain",
})
}
>
<SelectTrigger
aria-label="关键词正向或负向要求"
className="h-8 border-hairline-strong bg-background text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contain">应包含</SelectItem>
<SelectItem value="not_contain">不应包含</SelectItem>
</SelectContent>
</Select>
<Select
value={behavior.keywordMatchMode}
onValueChange={(value) =>
onChange({
...behavior,
keywordMatchMode: value as KeywordMatchMode,
})
}
>
<SelectTrigger
aria-label="关键词匹配方式"
className="h-8 border-hairline-strong bg-background text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">
{KEYWORD_MATCH_MODE_LABEL.any}
</SelectItem>
<SelectItem value="all">
{KEYWORD_MATCH_MODE_LABEL.all}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</>
) : (
<div className="grid gap-2 sm:grid-cols-[88px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs text-muted-soft">判断标准</div>
<Textarea
value={behavior.llmCriteria}
onChange={(event) =>
onChange({ ...behavior, llmCriteria: event.target.value })
}
rows={3}
placeholder="应确认存在人员受伤,并告知用户正在转人工处理"
className="field-sizing-fixed min-h-[72px] resize-y border-hairline-strong bg-background text-sm"
/>
</div>
)}
</div>
);
}
function ToolCallBehaviorEditor({
behavior,
tools,
onChange,
}: {
behavior: ToolCallExpectedBehavior;
tools: EditorToolOption[];
onChange: (behavior: ToolCallExpectedBehavior) => void;
}) {
const selectedTool =
tools.find((tool) => tool.id === behavior.toolId) ??
tools.find((tool) => tool.functionName === behavior.functionName) ??
null;
const schemaParams = selectedTool?.parameters ?? [];
function selectTool(toolId: string) {
const tool = tools.find((item) => item.id === toolId);
if (!tool) return;
onChange({
...behavior,
toolId: tool.id,
functionName: tool.functionName,
paramAssertions: [],
});
}
function paramAssertion(name: string): ToolParamAssertion | null {
return (
behavior.paramAssertions.find((item) => item.name === name) ?? null
);
}
function setParamMode(name: string, mode: ToolParamMatchMode | "none") {
const rest = behavior.paramAssertions.filter((item) => item.name !== name);
if (mode === "none") {
onChange({ ...behavior, paramAssertions: rest });
return;
}
const existing = paramAssertion(name);
onChange({
...behavior,
paramAssertions: [
...rest,
{
name,
matchMode: mode,
value: existing?.value ?? "",
},
],
});
}
function setParamValue(name: string, value: string) {
const existing = paramAssertion(name);
if (!existing) return;
onChange({
...behavior,
paramAssertions: behavior.paramAssertions.map((item) =>
item.name === name ? { ...item, value } : item,
),
});
}
return (
<div className="space-y-2.5">
<div className="grid gap-2 sm:grid-cols-[88px_minmax(0,1fr)] sm:items-center">
<div className="text-xs text-muted-soft">调用要求</div>
<Select
value={behavior.expectation}
onValueChange={(value) =>
onChange({
...behavior,
expectation: value as ToolCallExpectedBehavior["expectation"],
})
}
>
<SelectTrigger
aria-label="工具调用要求"
className="h-8 border-hairline-strong bg-background text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="called">应调用</SelectItem>
<SelectItem value="not_called">不应调用</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2 sm:grid-cols-[88px_minmax(0,1fr)] sm:items-center">
<div className="text-xs text-muted-soft">预期工具</div>
<Select
value={behavior.toolId}
onValueChange={selectTool}
>
<SelectTrigger className="h-8 border-hairline-strong bg-background text-xs">
<SelectValue placeholder="选择工具…" />
</SelectTrigger>
<SelectContent>
{tools.map((tool) => (
<SelectItem key={tool.id} value={tool.id}>
{tool.functionName}
{tool.label && tool.label !== tool.functionName
? ` · ${tool.label}`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTool && behavior.expectation === "called" && (
<div className="grid gap-2 sm:grid-cols-[88px_minmax(0,1fr)] sm:items-center">
<div className="text-xs text-muted-soft">调用次数</div>
<div className="flex flex-wrap items-center gap-2">
<Input
type="number"
min={1}
step={1}
value={behavior.minCalls}
onChange={(event) => {
const next = Number(event.target.value);
onChange({
...behavior,
minCalls: Number.isFinite(next) ? Math.max(1, Math.round(next)) : 1,
});
}}
aria-label="最少调用次数"
className="h-8 w-20 border-hairline-strong bg-background text-xs"
/>
<span className="text-xs text-muted-soft">至</span>
<Input
type="number"
min={behavior.minCalls}
step={1}
value={behavior.maxCalls ?? ""}
onChange={(event) => {
const raw = event.target.value;
const next = Number(raw);
onChange({
...behavior,
maxCalls:
raw === "" || !Number.isFinite(next)
? null
: Math.max(behavior.minCalls, Math.round(next)),
});
}}
placeholder="不限"
aria-label="最多调用次数"
className="h-8 w-20 border-hairline-strong bg-background text-xs"
/>
<span className="text-xs text-muted-soft">次(上限可留空)</span>
</div>
</div>
)}
{selectedTool && behavior.expectation === "called" && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
参数要求
</div>
{schemaParams.length === 0 ? (
<p className="text-xs text-muted-soft">该工具无参数可校验</p>
) : (
<div className="space-y-2.5">
{schemaParams.map((param) => {
const assertion = paramAssertion(param.name);
const mode = assertion?.matchMode ?? "none";
return (
<div
key={param.name}
className="space-y-1.5 rounded-lg border border-hairline/80 bg-background/60 px-2.5 py-2"
>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 font-mono text-xs text-foreground">
{param.name}
</span>
<Select
value={mode}
onValueChange={(value) =>
setParamMode(
param.name,
value as ToolParamMatchMode | "none",
)
}
>
<SelectTrigger className="h-7 w-[110px] border-hairline-strong bg-background text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">不校验</SelectItem>
<SelectItem value="exact">
{TOOL_PARAM_MATCH_MODE_LABEL.exact}
</SelectItem>
<SelectItem value="regex">
{TOOL_PARAM_MATCH_MODE_LABEL.regex}
</SelectItem>
<SelectItem value="llm">
{TOOL_PARAM_MATCH_MODE_LABEL.llm}
</SelectItem>
</SelectContent>
</Select>
</div>
{assertion && (
assertion.matchMode === "llm" ? (
<Textarea
value={assertion.value}
onChange={(event) =>
setParamValue(param.name, event.target.value)
}
rows={2}
placeholder="应表达由于存在人员受伤,需要转人工处理"
className="field-sizing-fixed min-h-[56px] resize-y border-hairline-strong bg-background text-xs"
/>
) : (
<Input
value={assertion.value}
onChange={(event) =>
setParamValue(param.name, event.target.value)
}
placeholder={
assertion.matchMode === "regex"
? "^ACC-[0-9]{8}$"
: "期望值"
}
className="h-8 border-hairline-strong bg-background text-xs"
/>
)
)}
</div>
);
})}
</div>
)}
<p className="text-[11px] leading-4 text-muted-soft">
默认不校验参数;仅主动配置的参数参与断言。
</p>
</div>
)}
{selectedTool && behavior.expectation === "called" && (
<div className="space-y-2 rounded-lg border border-violet-500/20 bg-background/60 p-2.5">
<div>
<div className="text-xs font-medium text-foreground">
Mock 工具响应
</div>
<p className="mt-0.5 text-[11px] leading-4 text-muted-soft">
工具被调用后将此结果注入 pipeline,使下一轮可稳定复现。
</p>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_112px]">
<Select
value={behavior.mockResponse.outcome}
onValueChange={(value) =>
onChange({
...behavior,
mockResponse: {
...behavior.mockResponse,
outcome: value as "success" | "error",
},
})
}
>
<SelectTrigger
aria-label="Mock 工具响应类型"
className="h-8 border-hairline-strong bg-background text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="success">成功响应</SelectItem>
<SelectItem value="error">错误响应</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={0}
max={60000}
step={100}
value={behavior.mockResponse.delayMs}
onChange={(event) => {
const next = Number(event.target.value);
onChange({
...behavior,
mockResponse: {
...behavior.mockResponse,
delayMs: Number.isFinite(next)
? Math.min(60000, Math.max(0, Math.round(next)))
: 0,
},
});
}}
aria-label="Mock 工具响应延迟"
className="h-8 border-hairline-strong bg-background text-xs"
/>
<span className="text-[11px] text-muted-soft">ms</span>
</div>
</div>
<Textarea
value={behavior.mockResponse.body}
onChange={(event) =>
onChange({
...behavior,
mockResponse: {
...behavior.mockResponse,
body: event.target.value,
},
})
}
rows={5}
aria-label="Mock 工具响应 JSON"
placeholder={'{\n "status": "ok"\n}'}
className="field-sizing-fixed min-h-[112px] resize-y border-hairline-strong bg-background font-mono text-xs"
/>
</div>
)}
{selectedTool && behavior.expectation === "not_called" && (
<p className="rounded-lg border border-hairline bg-background/60 px-2.5 py-2 text-[11px] leading-4 text-muted-soft">
本轮该工具的允许调用次数为 0;参数与 Mock 响应无需配置。
</p>
)}
</div>
);
}