382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { MonitorSmartphone, Plus, Server, Trash2, Wrench } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
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 {
|
|
createToolSimulation,
|
|
getToolSimulationValidationMessage,
|
|
type FixedInputTurn,
|
|
type ToolSimulation,
|
|
type ToolSimulationResponse,
|
|
} from "@/data/test-suites";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type EditorToolOption = {
|
|
id: string;
|
|
functionName: string;
|
|
label: string;
|
|
type: "http" | "mcp" | "client";
|
|
parameters: { name: string; description: string }[];
|
|
};
|
|
|
|
const TOOL_TYPE_LABEL: Record<EditorToolOption["type"], string> = {
|
|
http: "服务端 · HTTP",
|
|
mcp: "服务端 · MCP",
|
|
client: "客户端交互",
|
|
};
|
|
|
|
export function ToolSimulationEditor({
|
|
value,
|
|
tools,
|
|
turns,
|
|
onChange,
|
|
}: {
|
|
value: ToolSimulation[];
|
|
tools: EditorToolOption[];
|
|
turns: FixedInputTurn[];
|
|
onChange: (value: ToolSimulation[]) => void;
|
|
}) {
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const configuredIds = useMemo(
|
|
() => new Set(value.map((simulation) => simulation.toolId)),
|
|
[value],
|
|
);
|
|
const availableTools = tools.filter((tool) => !configuredIds.has(tool.id));
|
|
const turnIds = useMemo(() => new Set(turns.map((turn) => turn.id)), [turns]);
|
|
|
|
function addTool(tool: EditorToolOption) {
|
|
onChange([
|
|
...value,
|
|
createToolSimulation({
|
|
toolId: tool.id,
|
|
functionName: tool.functionName,
|
|
toolType: tool.type,
|
|
}),
|
|
]);
|
|
setAddOpen(false);
|
|
}
|
|
|
|
function updateSimulation(index: number, simulation: ToolSimulation) {
|
|
onChange(
|
|
value.map((item, itemIndex) =>
|
|
itemIndex === index ? simulation : item,
|
|
),
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{value.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-4 py-7 text-center">
|
|
<Wrench size={18} className="mx-auto text-muted-soft" />
|
|
<p className="mt-2 text-xs font-medium text-foreground">
|
|
暂未配置工具模拟
|
|
</p>
|
|
<p className="mx-auto mt-1 max-w-md text-[11px] leading-4 text-muted-soft">
|
|
运行中调用未配置的外部工具将立即停止当前用例。
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2.5">
|
|
{value.map((simulation, index) => {
|
|
const tool =
|
|
tools.find((candidate) => candidate.id === simulation.toolId) ??
|
|
tools.find(
|
|
(candidate) =>
|
|
candidate.functionName === simulation.functionName,
|
|
) ??
|
|
null;
|
|
return (
|
|
<ToolSimulationCard
|
|
key={simulation.toolId}
|
|
simulation={simulation}
|
|
tool={tool}
|
|
validationMessage={getToolSimulationValidationMessage(
|
|
simulation,
|
|
turnIds,
|
|
)}
|
|
onChange={(next) => updateSimulation(index, next)}
|
|
onRemove={() =>
|
|
onChange(value.filter((_, itemIndex) => itemIndex !== index))
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<Popover open={addOpen} onOpenChange={setAddOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 gap-1.5 rounded-full border-hairline-strong text-xs text-muted-foreground"
|
|
disabled={availableTools.length === 0}
|
|
>
|
|
<Plus size={14} />
|
|
添加工具模拟
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-80 p-1.5">
|
|
<div className="px-2.5 py-1.5 text-[11px] font-medium tracking-wide text-muted-soft uppercase">
|
|
选择工具
|
|
</div>
|
|
{availableTools.length === 0 ? (
|
|
<p className="px-2.5 py-3 text-xs text-muted-foreground">
|
|
所有可用工具均已配置
|
|
</p>
|
|
) : (
|
|
availableTools.map((tool) => (
|
|
<button
|
|
key={tool.id}
|
|
type="button"
|
|
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-surface-strong"
|
|
onClick={() => addTool(tool)}
|
|
>
|
|
<ToolTypeIcon type={tool.type} />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium text-foreground">
|
|
{tool.label || tool.functionName}
|
|
</span>
|
|
<span className="block truncate font-mono text-[11px] text-muted-soft">
|
|
{tool.functionName}
|
|
</span>
|
|
</span>
|
|
<span className="shrink-0 rounded-full border border-hairline px-2 py-0.5 text-[10px] text-muted-soft">
|
|
{TOOL_TYPE_LABEL[tool.type]}
|
|
</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToolSimulationCard({
|
|
simulation,
|
|
tool,
|
|
validationMessage,
|
|
onChange,
|
|
onRemove,
|
|
}: {
|
|
simulation: ToolSimulation;
|
|
tool: EditorToolOption | null;
|
|
validationMessage: string | null;
|
|
onChange: (simulation: ToolSimulation) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const toolType = tool?.type;
|
|
const response =
|
|
simulation.mode === "fixed"
|
|
? simulation.response
|
|
: simulation.steps[0]
|
|
? responseFromLegacyStep(simulation.steps[0])
|
|
: defaultResponse(toolType, simulation.functionName);
|
|
|
|
function updateFixedResponse(nextResponse: ToolSimulationResponse) {
|
|
onChange({
|
|
toolId: simulation.toolId,
|
|
functionName: simulation.functionName,
|
|
mode: "fixed",
|
|
response: nextResponse,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-xl border border-hairline bg-canvas-soft/30 p-3",
|
|
validationMessage && "border-destructive/40",
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-2.5">
|
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-muted-foreground">
|
|
{toolType ? <ToolTypeIcon type={toolType} /> : <Wrench size={14} />}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-foreground">
|
|
{tool?.label || simulation.functionName}
|
|
</span>
|
|
<span className="rounded-full border border-hairline px-2 py-0.5 text-[10px] text-muted-soft">
|
|
{toolType ? TOOL_TYPE_LABEL[toolType] : "工具已不可用"}
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-soft">
|
|
{simulation.functionName}
|
|
</div>
|
|
</div>
|
|
<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={`删除 ${simulation.functionName} 工具模拟`}
|
|
>
|
|
<Trash2 size={13} />
|
|
</Button>
|
|
</div>
|
|
|
|
{simulation.mode === "sequence" ? (
|
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-hairline bg-surface-strong/45 px-3 py-2">
|
|
<p className="text-[11px] leading-4 text-muted-foreground">
|
|
这是旧版顺序配置。转换后将复用第一条响应作为固定返回。
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="h-7 rounded-full border border-hairline-strong px-2.5 text-[11px] text-foreground transition-colors hover:bg-background"
|
|
onClick={() => updateFixedResponse(response)}
|
|
>
|
|
转为固定返回
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-3 rounded-lg border border-hairline bg-background/60 p-2.5">
|
|
<SimulationResponseFields
|
|
value={response}
|
|
toolType={toolType}
|
|
ariaPrefix={`${simulation.functionName} 固定返回`}
|
|
onChange={updateFixedResponse}
|
|
/>
|
|
<p className="mt-2 text-[11px] leading-4 text-muted-soft">
|
|
每次调用都返回此结果;调用次数仍由预期行为单独判断。
|
|
</p>
|
|
</div>
|
|
|
|
{validationMessage ? (
|
|
<p className="mt-2 text-[11px] text-destructive" role="alert">
|
|
{validationMessage}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SimulationResponseFields({
|
|
value,
|
|
toolType,
|
|
ariaPrefix,
|
|
onChange,
|
|
}: {
|
|
value: ToolSimulationResponse;
|
|
toolType: EditorToolOption["type"] | undefined;
|
|
ariaPrefix: string;
|
|
onChange: (value: ToolSimulationResponse) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_112px]">
|
|
<Select
|
|
value={value.outcome}
|
|
onValueChange={(outcome) =>
|
|
onChange({ ...value, outcome: outcome as "success" | "error" })
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
aria-label={`${ariaPrefix}状态`}
|
|
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={value.delayMs}
|
|
onChange={(event) => {
|
|
const next = Number(event.target.value);
|
|
onChange({
|
|
...value,
|
|
delayMs: Number.isFinite(next)
|
|
? Math.min(60000, Math.max(0, Math.round(next)))
|
|
: 0,
|
|
});
|
|
}}
|
|
aria-label={`${ariaPrefix}延迟`}
|
|
className="h-8 border-hairline-strong bg-background text-xs"
|
|
/>
|
|
<span className="text-[11px] text-muted-soft">ms</span>
|
|
</div>
|
|
</div>
|
|
<Textarea
|
|
value={value.body}
|
|
onChange={(event) => onChange({ ...value, body: event.target.value })}
|
|
rows={5}
|
|
aria-label={`${ariaPrefix} JSON`}
|
|
placeholder={
|
|
toolType === "client"
|
|
? '{\n "action": "selected",\n "value": "basic"\n}'
|
|
: '{\n "status": "ok"\n}'
|
|
}
|
|
className="field-sizing-fixed min-h-[112px] resize-y border-hairline-strong bg-background font-mono text-xs"
|
|
/>
|
|
{toolType === "client" ? (
|
|
<p className="text-[11px] leading-4 text-muted-soft">
|
|
JSON 表示客户端回传给 Agent 的用户操作,例如选择、关闭或取消。
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function defaultResponse(
|
|
toolType: EditorToolOption["type"] | undefined,
|
|
functionName: string,
|
|
): ToolSimulationResponse {
|
|
return {
|
|
outcome: "success",
|
|
body:
|
|
toolType === "client"
|
|
? functionName === "show_message"
|
|
? '{\n "action": "confirmed"\n}'
|
|
: '{\n "action": "selected",\n "value": ""\n}'
|
|
: '{\n "status": "ok"\n}',
|
|
delayMs: 0,
|
|
};
|
|
}
|
|
|
|
function responseFromLegacyStep(
|
|
step: Extract<ToolSimulation, { mode: "sequence" }>["steps"][number],
|
|
): ToolSimulationResponse {
|
|
return {
|
|
outcome: step.outcome,
|
|
body: step.body,
|
|
delayMs: step.delayMs,
|
|
};
|
|
}
|
|
|
|
function ToolTypeIcon({ type }: { type: EditorToolOption["type"] }) {
|
|
if (type === "client") {
|
|
return <MonitorSmartphone size={14} className="shrink-0" />;
|
|
}
|
|
return <Server size={14} className="shrink-0" />;
|
|
}
|