First Commit
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
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
This commit is contained in:
@@ -0,0 +1,956 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user