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

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