1694 lines
58 KiB
TypeScript
1694 lines
58 KiB
TypeScript
"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>
|
||
);
|
||
}
|