Files
ARTEX/web/src/app/(main)/system/tools/page.tsx
T
2026-08-25 12:05:26 +08:00

844 lines
34 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 * as React from "react";
import { toast } from "sonner";
import { PlayIcon, PlusIcon, RotateCcwIcon, SaveIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { api } from "@/lib/api";
import type { Agent, Tool } from "@/lib/types";
// Traffic tools are host tools gated by the global 流量捕获 switch: bindable, but
// only usable when capture is on. Keep in sync with traffic.SeedToolMetas.
const TRAFFIC_TOOL_KEYS = new Set(["traffic_search", "traffic_get"]);
// paramRows flattens schema.properties into an ordered row list for editing.
// name/type/required are read-only (welded to the Go handler); description/default
// are editable. Non-scalar params (array/object) can't carry an editable default.
// parentKey is set for rows that live inside an array param's items.properties.
type ParamRow = {
name: string;
type: string;
required: boolean;
description: string;
defaultStr: string; // "" = no default declared
scalar: boolean;
parentKey?: string; // set when this is a sub-field of an array param's items
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function toRows(schema: Record<string, any>): ParamRow[] {
const props = (schema?.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>((schema?.required as string[]) ?? []);
const rows: ParamRow[] = [];
for (const [name, p] of Object.entries(props)) {
const type = String(p?.type ?? "");
const hasDefault = p != null && "default" in p && (p as Record<string, unknown>).default != null;
rows.push({
name, type,
required: required.has(name),
description: String(p?.description ?? ""),
defaultStr: hasDefault ? String((p as Record<string, unknown>).default) : "",
scalar: ["string", "integer", "number", "boolean"].includes(type),
});
// Expand items.properties for array params so sub-fields are editable.
if (type === "array") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemProps = (p as any)?.items?.properties as Record<string, Record<string, unknown>> | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemRequired = new Set<string>(((p as any)?.items?.required as string[]) ?? []);
if (itemProps) {
for (const [subName, subP] of Object.entries(itemProps)) {
const subType = String((subP as Record<string, unknown>)?.type ?? "");
const subHasDefault = subP != null && "default" in subP && subP.default != null;
rows.push({
name: subName, type: subType,
required: itemRequired.has(subName),
description: String(subP?.description ?? ""),
defaultStr: subHasDefault ? String(subP.default) : "",
scalar: ["string", "integer", "number", "boolean"].includes(subType),
parentKey: name,
});
}
}
}
}
return rows;
}
// coerceDefault turns the edited default string back into a typed JSON value, or
// undefined to drop the "default" key entirely.
function coerceDefault(type: string, raw: string): unknown {
const s = raw.trim();
if (s === "") return undefined;
if (type === "integer" || type === "number") {
const n = Number(s);
return Number.isNaN(n) ? undefined : n;
}
if (type === "boolean") return s === "true";
return raw;
}
// applyRows writes edited rows back into a deep-copied schema (structure untouched).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applyRows(schema: Record<string, any>, rows: ParamRow[]): Record<string, any> {
const next = structuredClone(schema ?? {});
const props = (next.properties ?? {}) as Record<string, Record<string, unknown>>;
for (const r of rows) {
if (r.parentKey) {
// Sub-field of an array param's items.properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parent = props[r.parentKey] as any;
const subP = parent?.items?.properties?.[r.name] as Record<string, unknown> | undefined;
if (!subP) continue;
subP.description = r.description;
if (r.scalar) {
const dv = coerceDefault(r.type, r.defaultStr);
if (dv === undefined) delete subP.default;
else subP.default = dv;
}
} else {
const p = props[r.name];
if (!p) continue;
p.description = r.description;
if (r.scalar) {
const dv = coerceDefault(r.type, r.defaultStr);
if (dv === undefined) delete p.default;
else p.default = dv;
}
}
}
return next;
}
// ToolEditor is the full edit form for one tool, rendered inside the drawer.
function ToolEditor({
tool,
agents,
captureOn,
onSaved,
onClose,
}: {
tool: Tool;
agents: Agent[];
captureOn: boolean;
onSaved: () => void;
onClose: () => void;
}) {
// traffic tools can't be bound/enabled until the global 流量捕获 switch is on.
const trafficGated = TRAFFIC_TOOL_KEYS.has(tool.key) && !captureOn;
const [description, setDescription] = React.useState(tool.description);
const [bound, setBound] = React.useState<string[]>(tool.agents);
const [enabled, setEnabled] = React.useState(tool.enabled);
const [rows, setRows] = React.useState<ParamRow[]>(() => toRows(tool.schema));
const [saving, setSaving] = React.useState(false);
const setRow = (i: number, patch: Partial<ParamRow>) =>
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const toggleAgent = (k: string) =>
setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
async function save() {
setSaving(true);
try {
await api.saveTool(tool.key, {
description,
schema: applyRows(tool.schema, rows),
agents: bound,
enabled,
});
toast.success(`已保存工具「${tool.key}」`);
onSaved();
onClose();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function reset() {
try {
await api.resetTool(tool.key);
toast.success(`已恢复「${tool.key}」为代码默认`);
onSaved();
onClose();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
{trafficGated && (
<div className="border-amber-500/40 bg-amber-500/10 text-muted-foreground rounded-md border px-3 py-2 text-xs">
该工具依赖<b>流量捕获</b>。请先在「系统配置」开启流量捕获,才能绑定给 Agent 并启用。
</div>
)}
{/* binding + switch */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">绑定 Agent(决定该工具给哪些 Agent)</Label>
<div className="flex flex-wrap gap-3">
{agents.map((ag) => (
<label key={ag.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={bound.includes(ag.key)}
disabled={trafficGated}
onCheckedChange={() => toggleAgent(ag.key)}
/>
{ag.name}
<span className="text-muted-foreground font-mono text-xs">{ag.key}</span>
</label>
))}
{agents.length === 0 && <span className="text-muted-foreground text-xs">(无 Agent)</span>}
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} id={`en-${tool.key}`} />
<Label htmlFor={`en-${tool.key}`} className="text-sm">
启用
</Label>
</div>
</div>
{/* description */}
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">工具描述(发送给模型)</Label>
<Textarea
className="font-mono text-xs"
rows={6}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* params */}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">
参数(名称 / 类型 / 必填只读;描述与默认值可改)
</Label>
{rows.length === 0 && <span className="text-muted-foreground text-xs">(无参数)</span>}
{rows.map((r, i) => (
<div
key={(r.parentKey ?? "") + "." + r.name}
className={r.parentKey
? "border-l-2 border-muted ml-3 pl-3 grid gap-2 py-2"
: "grid gap-2 rounded-md border p-3"
}
>
<div className="flex flex-wrap items-center gap-2">
{r.parentKey && <span className="text-muted-foreground font-mono text-[10px]">↳</span>}
<span className="font-mono text-sm">{r.name}</span>
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{r.type || "?"}
</Badge>
{r.required && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
必填
</Badge>
)}
{r.parentKey && (
<span className="text-muted-foreground text-[10px]">items 子字段</span>
)}
</div>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="text-muted-foreground text-[11px]">描述</Label>
<Input
className="text-xs"
value={r.description}
onChange={(e) => setRow(i, { description: e.target.value })}
/>
</div>
<div className="grid gap-1">
<Label className="text-muted-foreground text-[11px]">默认值</Label>
<Input
className="text-xs"
placeholder={r.scalar ? "(空=无默认)" : "仅标量支持"}
disabled={!r.scalar}
value={r.defaultStr}
onChange={(e) => setRow(i, { defaultStr: e.target.value })}
/>
</div>
</div>
</div>
))}
</div>
</div>
<Separator className="mt-4" />
<div className="flex flex-wrap gap-2 p-4">
<Button size="sm" onClick={save} disabled={saving}>
<SaveIcon /> 保存
</Button>
<Button size="sm" variant="outline" onClick={reset}>
<RotateCcwIcon /> 恢复默认
</Button>
</div>
</div>
);
}
// ToolGridCard is one clickable tile in the catalog grid.
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-ring flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-medium">{tool.key}</span>
{tool.system ? (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
系统
</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
自定义·{tool.kind}
</Badge>
)}
{tool.deferred && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
deferred
</Badge>
)}
{!tool.enabled && (
<Badge variant="outline" className="text-destructive px-1.5 py-0 text-[10px]">
已停用
</Badge>
)}
<Badge
variant="secondary"
className="ml-auto px-1.5 py-0 text-[10px] tabular-nums"
title={`累计调用 ${tool.calls ?? 0} 次`}
>
调用 {tool.calls ?? 0}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">
{tool.description || "(无描述)"}
</p>
<div className="mt-auto flex flex-wrap gap-1 pt-1">
{tool.agents.length === 0 && (
<span className="text-muted-foreground text-[10px]">(未绑定 Agent)</span>
)}
{tool.agents.map((a) => (
<Badge key={a} variant="outline" className="px-1.5 py-0 text-[10px]">
{a}
</Badge>
))}
</div>
</button>
);
}
export default function ToolsPage() {
const [tools, setTools] = React.useState<Tool[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [captureOn, setCaptureOn] = React.useState(false);
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const [customEdit, setCustomEdit] = React.useState<Tool | "new" | null>(null);
const reload = React.useCallback(() => {
api.tools().then(setTools).catch(() => setTools([]));
}, []);
React.useEffect(() => {
reload();
api.agents().then(setAgents).catch(() => {});
api.settings().then((s) => setCaptureOn(!!s.traffic_capture)).catch(() => {});
}, [reload]);
const [query, setQuery] = React.useState("");
const selected = tools.find((t) => t.key === selectedKey) ?? null;
const matchTool = React.useCallback((t: Tool) => {
const q = query.trim().toLowerCase();
if (!q) return true;
return (
t.key.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.agents.some((a) => a.toLowerCase().includes(q))
);
}, [query]);
const systemTools = tools.filter((t) => t.system && matchTool(t));
const customTools = tools.filter((t) => !t.system && matchTool(t));
const allSystemCount = tools.filter((t) => t.system).length;
const allCustomCount = tools.filter((t) => !t.system).length;
// clicking a built-in tool opens the binding drawer; a custom tool opens its full editor.
const openTool = (t: Tool) => (t.system ? setSelectedKey(t.key) : setCustomEdit(t));
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">工具</h1>
<p className="text-muted-foreground text-sm">系统工具的描述/绑定,以及自定义工具(command/script/http)</p>
</div>
<div className="relative w-64">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
<Input
className="h-8 pl-8 text-sm"
placeholder="搜索工具名、描述、Agent…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<Tabs defaultValue="system">
<TabsList>
<TabsTrigger value="system">系统工具</TabsTrigger>
<TabsTrigger value="custom">自定义工具</TabsTrigger>
</TabsList>
<TabsContent value="system">
<Card>
<CardHeader>
<CardTitle>系统工具</CardTitle>
<CardDescription>
{query.trim()
? `${systemTools.length} / ${allSystemCount} 个匹配,点击卡片编辑描述、参数默认值与 Agent 绑定`
: `共 ${allSystemCount} 个,点击卡片编辑描述、参数默认值与 Agent 绑定`}
</CardDescription>
</CardHeader>
<CardContent>
{systemTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? "没有匹配的系统工具" : "(暂无系统工具,等待后端 seed)"}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{systemTools.map((t) => (
<ToolGridCard key={t.key} tool={t} onClick={() => openTool(t)} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="custom">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>自定义工具</CardTitle>
<CardDescription>
{query.trim()
? `shell/command/script/http,${customTools.length} / ${allCustomCount} 个匹配,点击卡片编辑`
: `shell(bash 声明) / command(命令) / script(Python) / http(API),共 ${allCustomCount} 个,点击卡片编辑`}
</CardDescription>
</div>
<Button size="sm" onClick={() => setCustomEdit("new")}>
<PlusIcon /> 新建自定义工具
</Button>
</div>
</CardHeader>
<CardContent>
{customTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? "没有匹配的自定义工具" : "(暂无自定义工具,点击右上角「新建自定义工具」)"}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{customTools.map((t) => (
<ToolGridCard key={t.key} tool={t} onClick={() => openTool(t)} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Sheet open={!!selected} onOpenChange={(o) => !o && setSelectedKey(null)}>
<SheetContent
side="right"
className="gap-0 p-0 data-[side=right]:w-[30vw] data-[side=right]:sm:max-w-[30vw]"
>
{selected && (
<>
<SheetHeader className="px-4">
<SheetTitle className="font-mono">{selected.key}</SheetTitle>
<SheetDescription>编辑描述、参数默认值与 Agent 绑定</SheetDescription>
</SheetHeader>
<ToolEditor
key={selected.key}
tool={selected}
agents={agents}
captureOn={captureOn}
onSaved={reload}
onClose={() => setSelectedKey(null)}
/>
</>
)}
</SheetContent>
</Sheet>
<CustomToolDialog
edit={customEdit}
agents={agents}
onClose={() => setCustomEdit(null)}
onSaved={() => {
setCustomEdit(null);
reload();
}}
/>
</div>
);
}
// ---- 自定义工具编辑器 ----
type ExecState = {
command: string;
code: string;
method: string;
url: string;
headers: string;
body: string;
timeout_ms: string;
proxy: string;
use_recording_proxy: boolean;
};
function CustomToolDialog({
edit,
agents,
onClose,
onSaved,
}: {
edit: Tool | "new" | null;
agents: Agent[];
onClose: () => void;
onSaved: () => void;
}) {
const isNew = edit === "new";
const tool = edit && edit !== "new" ? edit : null;
const [key, setKey] = React.useState("");
const [description, setDescription] = React.useState("");
const [kind, setKind] = React.useState<"shell" | "command" | "script" | "http">("shell");
const [ex, setEx] = React.useState<ExecState>({
command: "", code: "", method: "GET", url: "", headers: "", body: "", timeout_ms: "", proxy: "", use_recording_proxy: false,
});
const [schemaText, setSchemaText] = React.useState("");
const [bound, setBound] = React.useState<string[]>([]);
const [deferred, setDeferred] = React.useState(false);
const [enabled, setEnabled] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [paramsText, setParamsText] = React.useState("");
const [testing, setTesting] = React.useState(false);
const [testResult, setTestResult] = React.useState<{ output: string; is_error: boolean } | null>(null);
// (re)load form state when opening.
React.useEffect(() => {
if (!edit) return;
setParamsText(""); setTestResult(null);
if (edit === "new") {
setKey(""); setDescription(""); setKind("shell");
setEx({ command: "", code: "", method: "GET", url: "", headers: "", body: "", timeout_ms: "", proxy: "", use_recording_proxy: false });
setSchemaText(""); setBound([]); setDeferred(false); setEnabled(true);
return;
}
const t = edit;
const e = (t.exec ?? {}) as Record<string, unknown>;
setKey(t.key);
setDescription(t.description);
setKind((t.kind as "shell" | "command" | "script" | "http") ?? "shell");
setEx({
command: String(e.command ?? ""),
code: String(e.code ?? ""),
method: String(e.method ?? "GET"),
url: String(e.url ?? ""),
headers: e.headers ? JSON.stringify(e.headers, null, 2) : "",
body: String(e.body ?? ""),
timeout_ms: e.timeout_ms ? String(e.timeout_ms) : "",
proxy: String(e.proxy ?? ""),
use_recording_proxy: !!e.use_recording_proxy,
});
setSchemaText(t.schema && Object.keys(t.schema).length ? JSON.stringify(t.schema, null, 2) : "");
setBound(t.agents ?? []);
setDeferred(!!t.deferred);
setEnabled(t.enabled);
}, [edit]);
const toggleAgent = (k: string) => setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
function buildExec(): Record<string, unknown> {
if (kind === "shell") return {};
const t = ex.timeout_ms ? Number(ex.timeout_ms) : undefined;
if (kind === "command") return { command: ex.command, timeout_ms: t };
if (kind === "script") return { code: ex.code, timeout_ms: t };
let headers: Record<string, string> = {};
if (ex.headers.trim()) {
try { headers = JSON.parse(ex.headers); } catch { /* validated on save */ }
}
return { method: ex.method, url: ex.url, headers, body: ex.body, timeout_ms: t, proxy: ex.proxy, use_recording_proxy: ex.use_recording_proxy };
}
async function save() {
if (isNew && !/^[a-z][a-z0-9_]*$/.test(key.trim())) {
toast.error("key 需小写字母开头,仅含小写字母/数字/下划线");
return;
}
let schema: Record<string, unknown> = {};
if (schemaText.trim()) {
try { schema = JSON.parse(schemaText); } catch { toast.error("参数 JSON Schema 格式错误"); return; }
}
if (kind === "http" && ex.headers.trim()) {
try { JSON.parse(ex.headers); } catch { toast.error("headers JSON 格式错误"); return; }
}
if (kind === "http") {
const props = (schema as { properties?: Record<string, unknown> }).properties;
if (!props || Object.keys(props).length === 0) {
toast.error("http 工具必须提供参数 JSON Schema(含 properties,不能留空)");
return;
}
}
setSaving(true);
const payload = { description, schema: kind === "shell" ? {} : schema, agents: bound, enabled, kind, exec: buildExec(), deferred: kind === "shell" ? false : deferred };
try {
if (isNew) await api.createCustomTool({ key: key.trim(), ...payload });
else await api.updateCustomTool(tool!.key, payload);
toast.success(isNew ? "已创建自定义工具" : "已保存");
onSaved();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function del() {
if (!tool) return;
try {
await api.deleteCustomTool(tool.key);
toast.success("已删除");
onSaved();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
// runTest dry-runs the CURRENT form (unsaved) with the sample params, so a
// template/script/request can be debugged before saving.
async function runTest() {
let params: Record<string, unknown> = {};
if (paramsText.trim()) {
try { params = JSON.parse(paramsText); } catch { toast.error("测试参数 JSON 格式错误"); return; }
}
if (kind === "http" && ex.headers.trim()) {
try { JSON.parse(ex.headers); } catch { toast.error("headers JSON 格式错误"); return; }
}
setTesting(true);
setTestResult(null);
try {
const r = await api.testCustomTool({ kind, exec: buildExec(), params });
setTestResult(r);
} catch (e) {
setTestResult({ output: (e as Error).message, is_error: true });
} finally {
setTesting(false);
}
}
return (
<Sheet open={!!edit} onOpenChange={(o) => !o && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw] data-[side=right]:min-w-[480px]"
>
<SheetHeader className="px-4">
<SheetTitle>{isNew ? "新建自定义工具" : `编辑 ${tool?.key}`}</SheetTitle>
<SheetDescription>shell=bash 环境声明(只需名称+描述,告知模型可用 bash 调用);command/script/http 需写执行规格。</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
<div className="grid gap-1.5">
<Label className="text-xs">Key</Label>
<Input className="font-mono" placeholder="如 nmap_scan" value={key} disabled={!isNew}
onChange={(e) => setKey(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">描述(发给模型)</Label>
<Textarea rows={2} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">类型</Label>
<Select value={kind} onValueChange={(v) => setKind(v as "shell" | "command" | "script" | "http")}>
<SelectTrigger className="w-56"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="shell">shell(bash 环境声明)</SelectItem>
<SelectItem value="command">command(shell 命令模板)</SelectItem>
<SelectItem value="script">script(Python 脚本)</SelectItem>
<SelectItem value="http">http(API 请求)</SelectItem>
</SelectContent>
</Select>
{kind === "shell" && (
<p className="text-muted-foreground text-xs">适合 nmap、sqlmap、ffuf 等出名工具——模型已知用法,只需声明"可在 bash 中调用"即可。名称+描述会追加到 Bash 工具描述里。</p>
)}
</div>
{kind === "command" && (
<div className="grid gap-1.5">
<Label className="text-xs">命令模板(占位符 {"{param}"},如 nmap -p {"{ports}"} {"{target}"})</Label>
<Textarea className="font-mono text-xs" rows={2} value={ex.command}
onChange={(e) => setEx({ ...ex, command: e.target.value })} />
</div>
)}
{kind === "script" && (
<div className="grid gap-1.5">
<Label className="text-xs">Python 正文(参数走 stdin JSON / os.environ["TOOL_X"])</Label>
<Textarea className="font-mono text-xs" rows={10} value={ex.code}
placeholder={'import json,sys\nargs=json.load(sys.stdin)\nprint(...)'}
onChange={(e) => setEx({ ...ex, code: e.target.value })} />
</div>
)}
{kind === "http" && (
<div className="grid gap-2">
<div className="flex gap-2">
<div className="grid gap-1.5">
<Label className="text-xs">Method</Label>
<Input className="w-24" value={ex.method} onChange={(e) => setEx({ ...ex, method: e.target.value })} />
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-xs">URL(可含 {"{param}"})</Label>
<Input className="font-mono text-xs" value={ex.url} onChange={(e) => setEx({ ...ex, url: e.target.value })} />
</div>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Headers(JSON,可含 {"{param}"})</Label>
<Textarea className="font-mono text-xs" rows={2} value={ex.headers}
placeholder={'{"Authorization": "Bearer {token}"}'} onChange={(e) => setEx({ ...ex, headers: e.target.value })} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Body(可含 {"{param}"})</Label>
<Textarea className="font-mono text-xs" rows={2} value={ex.body} onChange={(e) => setEx({ ...ex, body: e.target.value })} />
</div>
<div className="flex items-center gap-4">
<div className="grid gap-1.5">
<Label className="text-xs">代理 URL(空=直连)</Label>
<Input className="font-mono text-xs w-56" value={ex.proxy} onChange={(e) => setEx({ ...ex, proxy: e.target.value })} />
</div>
<label className="mt-4 flex items-center gap-2 text-sm">
<Checkbox checked={ex.use_recording_proxy} onCheckedChange={(v) => setEx({ ...ex, use_recording_proxy: !!v })} />
走记录代理
</label>
</div>
</div>
)}
{kind !== "shell" && (
<div className="flex items-center gap-3">
<div className="grid gap-1.5">
<Label className="text-xs">超时(ms,空=默认)</Label>
<Input type="number" className="w-32" value={ex.timeout_ms} onChange={(e) => setEx({ ...ex, timeout_ms: e.target.value })} />
</div>
</div>
)}
{kind !== "shell" && (
<div className="grid gap-1.5">
<Label className="text-xs">
参数 JSON Schema{kind === "http" ? "(http 工具必填,需含 properties)" : "(留空 = 自动给 {args} 薄壳)"}
</Label>
<Textarea className="font-mono text-xs" rows={4} value={schemaText}
placeholder={'{"type":"object","properties":{"target":{"type":"string"}},"required":["target"]}'}
onChange={(e) => setSchemaText(e.target.value)} />
</div>
)}
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">绑定 Agent</Label>
<div className="flex flex-wrap gap-3">
{agents.map((a) => (
<label key={a.key} className="flex items-center gap-2 text-sm">
<Checkbox checked={bound.includes(a.key)} onCheckedChange={() => toggleAgent(a.key)} />
{a.name}<span className="text-muted-foreground font-mono text-xs">{a.key}</span>
</label>
))}
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} /> 启用
</label>
{kind !== "shell" && (
<label className="flex items-center gap-2 text-sm">
<Switch checked={deferred} onCheckedChange={setDeferred} /> deferred(大量不常用工具才开)
</label>
)}
</div>
{kind !== "shell" && (
<div className="grid gap-1.5 rounded-md border p-3">
<Label className="text-xs font-medium">测试运行(用当前表单,不会保存)</Label>
<Textarea className="font-mono text-xs" rows={2} value={paramsText}
placeholder={'示例参数 JSON,如 {"target":"example.com"}'}
onChange={(e) => setParamsText(e.target.value)} />
<div>
<Button size="sm" variant="outline" onClick={runTest} disabled={testing}>
<PlayIcon /> {testing ? "运行中…" : "测试运行"}
</Button>
</div>
{testResult && (
<pre
className={
"max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted p-2 font-mono text-xs " +
(testResult.is_error ? "text-destructive" : "")
}
>
{testResult.output || "(无输出)"}
</pre>
)}
</div>
)}
</div>
<Separator />
<div className="flex items-center gap-2 p-4">
<Button size="sm" onClick={save} disabled={saving}>
<SaveIcon /> {isNew ? "创建" : "保存"}
</Button>
{!isNew && (
<Button size="sm" variant="outline" className="text-destructive" onClick={del}>
<Trash2Icon /> 删除
</Button>
)}
</div>
</SheetContent>
</Sheet>
);
}