"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDown, ChevronUp, Copy, Loader2, MoreHorizontal, Pencil, Plus, Trash2, } from "lucide-react"; import { McpServersSection } from "@/components/tools/McpServersSection"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DataList, type DataListColumn } from "@/components/ui/data-list"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { FilterPills } from "@/components/ui/filter-pills"; import { ListToolbar } from "@/components/ui/list-toolbar"; import { PageHeader } from "@/components/ui/page-header"; import { SearchInput } from "@/components/ui/search-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { toolsApi, type HttpToolDefinition, type Tool, type ToolParameter, type ToolStatus, type ToolUpsert, } from "@/lib/api"; type ToolKind = "end_call" | "http" | "mcp"; type HttpMethod = HttpToolDefinition["config"]["method"]; type ToolFilter = "全部" | "End Call" | "HTTP" | "MCP"; type SortOrder = "newest" | "oldest"; const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP", "MCP"]; type ToolForm = { name: string; functionName: string; type: ToolKind; description: string; status: ToolStatus; messageType: "none" | "custom"; customMessage: string; captureReason: boolean; method: HttpMethod; url: string; timeoutSeconds: string; headers: string; secretHeaders: string; parameters: string; body: string; dynamicVariableAssignments: string; secretDynamicVariables: string; mcpServerId: string; remoteToolName: string; inputSchema: string; schemaHash: string; }; const EMPTY_OBJECT = "{}"; const EMPTY_ARRAY = "[]"; function blankForm(): ToolForm { return { name: "", functionName: "", type: "end_call", description: "", status: "active", messageType: "none", customMessage: "", captureReason: true, method: "GET", url: "", timeoutSeconds: "15", headers: EMPTY_OBJECT, secretHeaders: EMPTY_OBJECT, parameters: EMPTY_ARRAY, body: EMPTY_OBJECT, dynamicVariableAssignments: EMPTY_OBJECT, secretDynamicVariables: EMPTY_OBJECT, mcpServerId: "", remoteToolName: "", inputSchema: EMPTY_OBJECT, schemaHash: "", }; } function pretty(value: unknown, fallback: string): string { return JSON.stringify(value ?? JSON.parse(fallback), null, 2); } function formFromTool(tool: Tool): ToolForm { const base = { ...blankForm(), name: tool.name, functionName: tool.functionName }; base.type = tool.type; base.description = tool.description; base.status = tool.status; if (tool.definition.type === "end_call") { base.messageType = tool.definition.config.messageType; base.customMessage = tool.definition.config.customMessage; base.captureReason = tool.definition.config.captureReason; return base; } if (tool.definition.type === "mcp") { base.mcpServerId = tool.mcpServerId ?? ""; base.remoteToolName = tool.definition.config.remoteToolName; base.inputSchema = pretty(tool.definition.config.inputSchema, EMPTY_OBJECT); base.schemaHash = tool.definition.config.schemaHash; base.dynamicVariableAssignments = pretty( tool.definition.config.dynamicVariableAssignments ?? {}, EMPTY_OBJECT, ); return base; } base.method = tool.definition.config.method; base.url = tool.definition.config.url; base.timeoutSeconds = String(tool.definition.config.timeoutSeconds); base.headers = pretty(tool.definition.config.headers, EMPTY_OBJECT); base.parameters = pretty(tool.definition.config.parameters, EMPTY_ARRAY); base.body = pretty(tool.definition.config.body, EMPTY_OBJECT); base.dynamicVariableAssignments = pretty( tool.definition.config.dynamicVariableAssignments ?? {}, EMPTY_OBJECT, ); const secrets = tool.secrets as { headers?: Record; dynamic_variables?: Record; }; base.secretHeaders = pretty(secrets.headers ?? {}, EMPTY_OBJECT); base.secretDynamicVariables = pretty( secrets.dynamic_variables ?? {}, EMPTY_OBJECT, ); return base; } function parseObject(value: string, label: string): Record { let parsed: unknown; try { parsed = JSON.parse(value || EMPTY_OBJECT); } catch { throw new Error(`${label}不是有效的 JSON`); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { throw new Error(`${label}必须是 JSON 对象`); } return parsed as Record; } function parseParameters(value: string): ToolParameter[] { let parsed: unknown; try { parsed = JSON.parse(value || EMPTY_ARRAY); } catch { throw new Error("参数定义不是有效的 JSON"); } if (!Array.isArray(parsed)) throw new Error("参数定义必须是 JSON 数组"); return parsed as ToolParameter[]; } function functionNameFrom(value: string): string { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 64); } function payloadFromForm(form: ToolForm): ToolUpsert { if (!form.name.trim()) throw new Error("请输入工具名称"); if (!/^[a-z][a-z0-9_]{0,63}$/.test(form.functionName)) { throw new Error("函数名必须以小写字母开头,且只能包含小写字母、数字和下划线"); } if (form.type === "end_call") { return { name: form.name.trim(), functionName: form.functionName, description: form.description.trim(), status: form.status, secrets: {}, definition: { schemaVersion: 1, type: "end_call", config: { messageType: form.messageType, customMessage: form.messageType === "custom" ? form.customMessage : "", captureReason: form.captureReason, }, }, }; } if (form.type === "mcp") { return { name: form.name.trim(), functionName: form.functionName, description: form.description.trim(), status: form.status, mcpServerId: form.mcpServerId, secrets: {}, definition: { schemaVersion: 1, type: "mcp", config: { remoteToolName: form.remoteToolName, inputSchema: parseObject(form.inputSchema, "MCP Input Schema"), schemaHash: form.schemaHash, dynamicVariableAssignments: parseObject( form.dynamicVariableAssignments, "变量赋值", ) as Record, }, }, }; } const timeoutSeconds = Number(form.timeoutSeconds); if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) { throw new Error("超时时间必须是 1 到 120 秒之间的整数"); } const headers = parseObject(form.headers, "Header"); const secretHeaders = parseObject(form.secretHeaders, "敏感 Header"); const body = parseObject(form.body, "固定 Body"); const dynamicVariableAssignments = parseObject( form.dynamicVariableAssignments, "变量赋值", ); const secretDynamicVariables = parseObject( form.secretDynamicVariables, "密钥变量", ); const parameters = parseParameters(form.parameters); return { name: form.name.trim(), functionName: form.functionName, description: form.description.trim(), status: form.status, definition: { schemaVersion: 1, type: "http", config: { method: form.method, url: form.url.trim(), timeoutSeconds, headers: headers as Record, parameters, body, dynamicVariableAssignments: dynamicVariableAssignments as Record, }, }, secrets: { headers: secretHeaders, dynamic_variables: secretDynamicVariables, }, }; } function formatTimestamp(value?: string | null): string { if (!value) return "—"; return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }).format(new Date(value)); } function updatedAtValue(value?: string | null): number { const time = value ? new Date(value).getTime() : NaN; return Number.isNaN(time) ? 0 : time; } export function ComponentsToolsPage() { const [tools, setTools] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("全部"); const [sortOrder, setSortOrder] = useState("newest"); const [currentPage, setCurrentPage] = useState(1); const [dialogOpen, setDialogOpen] = useState(false); const [editing, setEditing] = useState(null); const [form, setForm] = useState(blankForm); const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(null); const [functionNameTouched, setFunctionNameTouched] = useState(false); const [deletingId, setDeletingId] = useState(null); const [duplicatingId, setDuplicatingId] = useState(null); const loadTools = useCallback(async () => { setLoading(true); setError(null); try { setTools(await toolsApi.list()); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : "加载工具失败"); } finally { setLoading(false); } }, []); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect void loadTools(); }, [loadTools]); const filteredTools = useMemo(() => { const query = search.trim().toLowerCase(); return tools.filter((tool) => (filter === "全部" || (filter === "End Call" && tool.type === "end_call") || (filter === "HTTP" && tool.type === "http") || (filter === "MCP" && tool.type === "mcp")) && (!query || [tool.name, tool.functionName, tool.description].some((value) => value.toLowerCase().includes(query), )), ); }, [filter, search, tools]); const sortedTools = useMemo(() => { return [...filteredTools].sort((a, b) => { const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt); if (diff !== 0) return sortOrder === "newest" ? diff : -diff; return a.id.localeCompare(b.id); }); }, [filteredTools, sortOrder]); const pageSize = 5; const totalPages = Math.max(1, Math.ceil(sortedTools.length / pageSize)); const safeCurrentPage = Math.min(currentPage, totalPages); const pageStart = (safeCurrentPage - 1) * pageSize; const pageEnd = pageStart + pageSize; const paginatedTools = sortedTools.slice(pageStart, pageEnd); function changeFilter(value: ToolFilter) { setFilter(value); setCurrentPage(1); } function changeSearch(value: string) { setSearch(value); setCurrentPage(1); } function openCreate() { setEditing(null); setForm(blankForm()); setFunctionNameTouched(false); setFormError(null); setDialogOpen(true); } function openEdit(tool: Tool) { setEditing(tool); setForm(formFromTool(tool)); setFunctionNameTouched(true); setFormError(null); setDialogOpen(true); } async function saveTool() { if (saving) return; setSaving(true); setFormError(null); try { const payload = payloadFromForm(form); if (editing) await toolsApi.update(editing.id, payload); else await toolsApi.create(payload); setDialogOpen(false); await loadTools(); } catch (saveError) { setFormError(saveError instanceof Error ? saveError.message : "保存失败"); } finally { setSaving(false); } } async function removeTool(tool: Tool) { if (!window.confirm(`确认删除工具“${tool.name}”?`)) return; setDeletingId(tool.id); try { await toolsApi.remove(tool.id); await loadTools(); } catch (removeError) { setError(removeError instanceof Error ? removeError.message : "删除失败"); } finally { setDeletingId(null); } } async function duplicateTool(id: string) { setDuplicatingId(id); try { await toolsApi.duplicate(id); await loadTools(); } catch (duplicateError) { setError(duplicateError instanceof Error ? duplicateError.message : "复制失败"); } finally { setDuplicatingId(null); } } const columns: DataListColumn[] = [ { key: "name", header: "工具名称", width: "md:w-[360px]", cell: (tool) => ( <>
{tool.name}
{tool.functionName}
), }, { key: "type", header: "类型", width: "md:w-[128px]", cell: (tool) => ( {tool.type === "end_call" ? "End Call" : tool.type === "mcp" ? "MCP" : "HTTP"} ), }, { key: "status", header: "状态", width: "md:w-[156px]", cell: (tool) => ( {tool.status === "active" ? "启用" : tool.status === "draft" ? "草稿" : "已归档"} ), }, { key: "updated", width: "md:w-[176px]", header: ( ), cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground", cell: (tool) => formatTimestamp(tool.updatedAt), }, { key: "actions", header: "操作", align: "right", cellClassName: "flex justify-end gap-2", cell: (tool) => ( <> { event.preventDefault(); void duplicateTool(tool.id); }} > {duplicatingId === tool.id ? ( ) : ( )} 复制 { event.preventDefault(); void removeTool(tool); }} > {deletingId === tool.id ? ( ) : ( )} 删除 ), }, ]; return (
添加工具 } />
} search={ } /> columns={columns} rows={paginatedTools} rowKey={(tool) => tool.id} loading={loading} loadingText="正在加载工具…" error={error} onRetry={() => void loadTools()} empty={{ title: tools.length === 0 ? "暂无工具资源" : "未找到匹配的工具资源", description: tools.length === 0 ? "点击右上角「添加工具」开始。" : "请调整关键词或筛选条件后再试。", }} pagination={{ page: safeCurrentPage, totalPages, onPageChange: setCurrentPage, summary: filteredTools.length === 0 ? "没有数据" : `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredTools.length)} / 共 ${filteredTools.length} 个工具资源`, }} />
{editing ? "编辑工具资源" : "添加工具资源"} 左侧配置工具的固定信息,右侧参数会随工具类型更新。
{ const name = event.target.value; setForm((current) => ({ ...current, name, functionName: functionNameTouched ? current.functionName : functionNameFrom(name), })); }} /> { setFunctionNameTouched(true); setForm((current) => ({ ...current, functionName: event.target.value })); }} placeholder="query_order" />