Files
artex/web/src/app/(main)/system/tools/page.tsx
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

957 lines
34 KiB
TypeScript

"use client";
import * as React from "react";
import { PlayIcon, PlusIcon, RotateCcwIcon, SaveIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
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;
}) {
const t = useTranslations("toolsPage");
// 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(t("savedTool", { key: tool.key }));
onSaved();
onClose();
} catch (e) {
toast.error(t("saveFailed", { msg: (e as Error).message }));
} finally {
setSaving(false);
}
}
async function reset() {
try {
await api.resetTool(tool.key);
toast.success(t("resetDone", { key: tool.key }));
onSaved();
onClose();
} catch (e) {
toast.error(t("resetFailed", { msg: (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">
{t.rich("trafficGatedNote", { b: (c) => <b>{c}</b> })}
</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">{t("bindAgentLabel")}</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">{t("noAgents")}</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">
{t("enabled")}
</Label>
</div>
</div>
{/* description */}
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">{t("descLabel")}</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">{t("paramsLabel")}</Label>
{rows.length === 0 && <span className="text-muted-foreground text-xs">{t("noParams")}</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]">
{t("required")}
</Badge>
)}
{r.parentKey && <span className="text-muted-foreground text-[10px]">{t("itemsSubfield")}</span>}
</div>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="text-muted-foreground text-[11px]">{t("rowDesc")}</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]">{t("rowDefault")}</Label>
<Input
className="text-xs"
placeholder={r.scalar ? t("defaultEmptyHint") : t("scalarOnly")}
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 /> {t("save")}
</Button>
<Button size="sm" variant="outline" onClick={reset}>
<RotateCcwIcon /> {t("resetDefault")}
</Button>
</div>
</div>
);
}
// ToolGridCard is one clickable tile in the catalog grid.
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
const t = useTranslations("toolsPage");
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]">
{t("badgeSystem")}
</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{t("badgeCustom")}·{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]">
{t("badgeDisabled")}
</Badge>
)}
<Badge
variant="secondary"
className="ml-auto px-1.5 py-0 text-[10px] tabular-nums"
title={t("callsTitle", { count: tool.calls ?? 0 })}
>
{t("callsBadge", { count: tool.calls ?? 0 })}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">{tool.description || t("noDescription")}</p>
<div className="mt-auto flex flex-wrap gap-1 pt-1">
{tool.agents.length === 0 && <span className="text-muted-foreground text-[10px]">{t("noBoundAgents")}</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 t = useTranslations("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 = (tool: Tool) => (tool.system ? setSelectedKey(tool.key) : setCustomEdit(tool));
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">{t("title")}</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</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={t("searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<Tabs defaultValue="system">
<TabsList>
<TabsTrigger value="system">{t("tabSystem")}</TabsTrigger>
<TabsTrigger value="custom">{t("tabCustom")}</TabsTrigger>
</TabsList>
<TabsContent value="system">
<Card>
<CardHeader>
<CardTitle>{t("tabSystem")}</CardTitle>
<CardDescription>
{query.trim()
? t("systemMatched", { shown: systemTools.length, total: allSystemCount })
: t("systemTotal", { total: allSystemCount })}
</CardDescription>
</CardHeader>
<CardContent>
{systemTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? t("systemNoMatch") : t("systemEmpty")}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{systemTools.map((tool) => (
<ToolGridCard key={tool.key} tool={tool} onClick={() => openTool(tool)} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="custom">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{t("tabCustom")}</CardTitle>
<CardDescription>
{query.trim()
? t("customMatched", { shown: customTools.length, total: allCustomCount })
: t("customTotal", { total: allCustomCount })}
</CardDescription>
</div>
<Button size="sm" onClick={() => setCustomEdit("new")}>
<PlusIcon /> {t("newCustom")}
</Button>
</div>
</CardHeader>
<CardContent>
{customTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? t("customNoMatch") : t("customEmpty")}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{customTools.map((tool) => (
<ToolGridCard key={tool.key} tool={tool} onClick={() => openTool(tool)} />
))}
</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>{t("editSheetDesc")}</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 t = useTranslations("toolsPage");
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(t("keyError"));
return;
}
let schema: Record<string, unknown> = {};
if (schemaText.trim()) {
try {
schema = JSON.parse(schemaText);
} catch {
toast.error(t("schemaError"));
return;
}
}
if (kind === "http" && ex.headers.trim()) {
try {
JSON.parse(ex.headers);
} catch {
toast.error(t("headersError"));
return;
}
}
if (kind === "http") {
const props = (schema as { properties?: Record<string, unknown> }).properties;
if (!props || Object.keys(props).length === 0) {
toast.error(t("httpSchemaRequired"));
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 ? t("createdCustom") : t("saved"));
onSaved();
} catch (e) {
toast.error(t("saveFailed", { msg: (e as Error).message }));
} finally {
setSaving(false);
}
}
async function del() {
if (!tool) return;
try {
await api.deleteCustomTool(tool.key);
toast.success(t("deleted"));
onSaved();
} catch (e) {
toast.error(t("deleteFailed", { msg: (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(t("testParamsError"));
return;
}
}
if (kind === "http" && ex.headers.trim()) {
try {
JSON.parse(ex.headers);
} catch {
toast.error(t("headersError"));
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);
}
}
let sheetTitle = t("newCustom");
if (!isNew && tool) sheetTitle = t("editCustom", { key: tool.key });
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>{sheetTitle}</SheetTitle>
<SheetDescription>{t("customSheetDesc")}</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={t("keyPlaceholder")}
value={key}
disabled={!isNew}
onChange={(e) => setKey(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">{t("descLabelShort")}</Label>
<Textarea rows={2} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">{t("kindLabel")}</Label>
<Select value={kind} onValueChange={(v) => setKind(v as "shell" | "command" | "script" | "http")}>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="shell">{t("kindShell")}</SelectItem>
<SelectItem value="command">{t("kindCommand")}</SelectItem>
<SelectItem value="script">{t("kindScript")}</SelectItem>
<SelectItem value="http">{t("kindHttp")}</SelectItem>
</SelectContent>
</Select>
{kind === "shell" && <p className="text-muted-foreground text-xs">{t("shellHint")}</p>}
</div>
{kind === "command" && (
<div className="grid gap-1.5">
<Label className="text-xs">{t("cmdTemplateLabel")}</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">{t("scriptLabel")}</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">{t("httpUrlLabel")}</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">{t("httpHeadersLabel")}</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">{t("httpBodyLabel")}</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">{t("proxyLabel")}</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 })}
/>
{t("useRecordingProxy")}
</label>
</div>
</div>
)}
{kind !== "shell" && (
<div className="flex items-center gap-3">
<div className="grid gap-1.5">
<Label className="text-xs">{t("timeoutLabel")}</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">
{t("schemaLabel")}
{kind === "http" ? t("schemaHttpSuffix") : t("schemaDefaultSuffix")}
</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">{t("bindAgentShort")}</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} /> {t("enabled")}
</label>
{kind !== "shell" && (
<label className="flex items-center gap-2 text-sm">
<Switch checked={deferred} onCheckedChange={setDeferred} /> {t("deferredLabel")}
</label>
)}
</div>
{kind !== "shell" && (
<div className="grid gap-1.5 rounded-md border p-3">
<Label className="text-xs font-medium">{t("testSectionLabel")}</Label>
<Textarea
className="font-mono text-xs"
rows={2}
value={paramsText}
placeholder={t("testParamsPlaceholder")}
onChange={(e) => setParamsText(e.target.value)}
/>
<div>
<Button size="sm" variant="outline" onClick={runTest} disabled={testing}>
<PlayIcon /> {testing ? t("testRunning") : t("testRun")}
</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 || t("noOutput")}
</pre>
)}
</div>
)}
</div>
<Separator />
<div className="flex items-center gap-2 p-4">
<Button size="sm" onClick={save} disabled={saving}>
<SaveIcon /> {isNew ? t("create") : t("save")}
</Button>
{!isNew && (
<Button size="sm" variant="outline" className="text-destructive" onClick={del}>
<Trash2Icon /> {t("delete")}
</Button>
)}
</div>
</SheetContent>
</Sheet>
);
}