import { useMemo, useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; import { adminListPlugins, adminSetPluginPolicy, adminPluginUninstall, getStoredRole } from "../../lib/api"; import type { AdminPluginRecord } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { useAuditStore } from "../audit"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; import { Button, Surface, Switch, Tag } from "../../components/primitives"; import { InstalledPluginsPanel } from "./plugins/InstalledPlugins"; import { mcpServerEntries, originGroupTitle, otherCapabilityEntries, toolOriginBuckets, } from "./pluginOrigin"; type CapKind = "tool" | "skill" | "channel"; const KIND_LABELS: Record = { tool: "工具 Tool", skill: "技能 Skill", channel: "渠道 Channel", }; /** 指向「MCP 服务器」页的提示文案:server 级动作(启停/重连/卸载)都在那边。 */ const MCP_PAGE_HINT = "在「MCP 服务器」页卸载该 server"; // Channel adapters are not gated by the tool policy (group/stranger markers are a // tool concept; a channel is configured via its own 渠道管理 page), so the // channel block shows identity + a pointer rather than the policy switches. function channelColumns(): AdminColumn[] { return [ { key: "name", title: "名称", render: (p) => {p.name} }, { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, { key: "admin", title: "配置", render: () => 渠道配置见「渠道管理」页 }, ]; } /** The environment-marker switches, shared by tool and skill rows. * * 它对**工具名**生效(`tool_policies` 以 name 为键),因此来自 MCP server 的工具有同 * 一套开关;`mcp:` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在 * MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */ function policyColumn( isOwner: boolean, busy: string | null, setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void, ): AdminColumn { return { key: "policy", title: "环境授权(owner)", render: (p) => ( {busy === p.name ? 保存中… : null} ), }; } /** Columns for a host-builtin tool / a skill row (uninstall = remove this capability). */ function registeredColumns( isOwner: boolean, busy: string | null, setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void, uninstall: (name: string) => void, ): AdminColumn[] { return [ { key: "name", title: "名称", render: (p) => {p.name} }, { key: "kind", title: "类型", render: (p) => {(KIND_LABELS as Record)[p.kind] ?? p.kind} }, { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, policyColumn(isOwner, busy, setPolicy), { key: "actions", title: "操作", render: (p) => ( ), }, ]; } /** Columns for a tool that belongs to an MCP server. * * 🔴 行内**没有**「卸载」:单把 MCP 工具不是独立安装单元 —— 卸载它只能绕过插件管理器 * 裸删注册表条目,而该 server 的 names 账本不同步(重连/refresh 后工具复活,用户以为 * 卸掉了)。服务端同样拒绝这条路径(`plugin_uninstall_scope`),这里给出该去哪里做。 */ function mcpToolColumns( isOwner: boolean, busy: string | null, setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void, ): AdminColumn[] { return [ { key: "name", title: "名称", render: (p) => {p.name} }, { key: "origin", title: "来源", render: (p) => {p.origin?.server || "未知 server"}, }, { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, policyColumn(isOwner, busy, setPolicy), { key: "actions", title: "操作", render: () => {MCP_PAGE_HINT}, }, ]; } /** Columns for a `kind:"mcp"` entry: one registration row per server, server-scoped. */ function mcpServerColumns(): AdminColumn[] { return [ { key: "name", title: "登记键", render: (p) => {p.name} }, { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, { key: "admin", title: "管理", render: () => ( 启停 / 重连 / 卸载 / 安装见「MCP 服务器」页;本行是注册表登记条目,不承载单独的工具策略 ), }, ]; } /** Columns for capability kinds the page does not otherwise model (e.g. iot). * * design/09 §5.2.1 红线③:已装但本页不认识的条目要**如实显示**,不得静默过滤。 */ function otherColumns(): AdminColumn[] { return [ { key: "name", title: "名称", render: (p) => {p.name} }, { key: "kind", title: "类型", render: (p) => {p.kind} }, { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, ]; } export function PluginsModule(): React.ReactNode { const { data, loading, error, reload } = useAsyncData(adminListPlugins, []); useAutoReload(reload, 10000); const isOwner = getStoredRole() === "owner"; const log = useAuditStore((s) => s.log); const [busy, setBusy] = useState(null); const [fail, setFail] = useState(null); const [msg, setMsg] = useState(null); const uninstall = (name: string) => { setBusy(name); setFail(null); setMsg(null); adminPluginUninstall(name) .then(() => { log("plugin.uninstall", "ok"); setMsg(`已卸载 ${name}`); reload(); }) .catch((err: unknown) => { log("plugin.uninstall", "error"); setFail(err instanceof Error ? err.message : "卸载失败"); }) .finally(() => setBusy(null)); }; const setPolicy = (name: string, allowInGroup: boolean, allowInStranger: boolean) => { setBusy(name); setFail(null); adminSetPluginPolicy(name, allowInGroup, allowInStranger) .then(() => { log(`policy.set:${name}`, "ok"); reload(); }) .catch((err: unknown) => { log(`policy.set:${name}`, "error"); setFail(err instanceof Error ? err.message : "策略更新失败"); }) .finally(() => setBusy(null)); }; const byKind = useMemo(() => { const groups: Record = { tool: [], skill: [], channel: [] }; for (const cap of data ?? []) { const k = cap.kind as CapKind; if (groups[k]) groups[k].push(cap); } return groups; }, [data]); // 工具段的两级分节:先按上游 server,再剩「宿主内置」。 const toolBuckets = useMemo(() => toolOriginBuckets(byKind.tool), [byKind.tool]); const mcpServers = useMemo(() => mcpServerEntries(data ?? []), [data]); const others = useMemo(() => otherCapabilityEntries(data ?? []), [data]); const toolCols = registeredColumns(isOwner, busy, setPolicy, uninstall); const mcpToolCols = mcpToolColumns(isOwner, busy, setPolicy); const channelCols = channelColumns(); const serverCols = mcpServerColumns(); const otherCols = otherColumns(); return (
{!isOwner ? 当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。 : null} {fail ? {fail} : null} {msg ? {msg} : null}

已注册能力

GET /api/v1/admin/plugins · 工具按上游 server 分节
{KIND_LABELS.tool} {byKind.tool.length} 个 · {toolBuckets.mcp.length} 台 MCP server + 宿主内置
{toolBuckets.mcp.map((group) => (
{originGroupTitle(group)} server 级动作见「MCP 服务器」页
`mcp:${group.key}:${p.name}`} />
))}
宿主内置工具 {toolBuckets.builtin.length} 把
`tool:${p.name}`} />
{KIND_LABELS.skill} {byKind.skill.length} 个
`skill:${p.name}`} />
{KIND_LABELS.channel} {byKind.channel.length} 个
`channel:${p.name}`} />
{/* 🔴 `kind:"mcp"` 条目**必须如实显示**(design/09 §5.2.1 红线③):它是每台 server 的注册登记,不是可调用能力,因此这里给事实 + 指向,不给策略开关 (`tool_policies` 只按工具名判定,对这条永远是惰性的 = 假开关)。 */}
MCP server 登记 {mcpServers.length} 台
`mcpentry:${p.name}`} />
{others.length > 0 ? (
其它能力 {others.length} 个 · 本页暂无专用视图,如实列出
`other:${p.name}`} />
) : null}
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由 tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 —— 单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
); }