- GET /admin/plugins 后端仍平铺返回(契约不变), 前端 Plugins.tsx 按 kind 分组渲染 - 工具+技能: 保留「群聊/陌生人」环境授权开关(ToolPolicy, owner可编辑)+卸载; 渠道: 仅名称/描述/『见渠道管理页』, 无策略开关(渠道走自身配置) - 渠道块文案tone=neutral, 工具技能accent; Miuix 原语复用
190 lines
7.4 KiB
TypeScript
190 lines
7.4 KiB
TypeScript
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";
|
||
|
||
type CapKind = "tool" | "skill" | "channel";
|
||
|
||
const KIND_LABELS: Record<CapKind, string> = {
|
||
tool: "工具 Tool",
|
||
skill: "技能 Skill",
|
||
channel: "渠道 Channel",
|
||
};
|
||
|
||
// 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<AdminPluginRecord>[] {
|
||
return [
|
||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||
{ key: "admin", title: "配置", render: () => <span className="text-xs text-[var(--mu-text-lo)]">渠道配置见「渠道管理」页</span> },
|
||
];
|
||
}
|
||
|
||
/** Columns for a tool / skill row (has environment markers, owner can edit). */
|
||
function registeredColumns(
|
||
isOwner: boolean,
|
||
busy: string | null,
|
||
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||
uninstall: (name: string) => void,
|
||
): AdminColumn<AdminPluginRecord>[] {
|
||
return [
|
||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> },
|
||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||
{
|
||
key: "policy",
|
||
title: "环境授权(owner)",
|
||
render: (p) => (
|
||
<span className="flex items-center gap-4">
|
||
<label className="flex items-center gap-1.5">
|
||
<Switch
|
||
checked={p.allowInGroup}
|
||
disabled={!isOwner || busy === p.name}
|
||
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
|
||
/>
|
||
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
|
||
</label>
|
||
<label className="flex items-center gap-1.5">
|
||
<Switch
|
||
checked={p.allowInStranger}
|
||
disabled={!isOwner || busy === p.name}
|
||
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
|
||
/>
|
||
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
|
||
</label>
|
||
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: "actions",
|
||
title: "操作",
|
||
render: (p) => (
|
||
<Button size="sm" variant="ghost" disabled={!isOwner || busy !== null} onClick={() => uninstall(p.name)}>
|
||
卸载
|
||
</Button>
|
||
),
|
||
},
|
||
];
|
||
}
|
||
|
||
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<string | null>(null);
|
||
const [fail, setFail] = useState<string | null>(null);
|
||
const [msg, setMsg] = useState<string | null>(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<CapKind, AdminPluginRecord[]> = { tool: [], skill: [], channel: [] };
|
||
for (const cap of data ?? []) {
|
||
const k = cap.kind as CapKind;
|
||
if (groups[k]) groups[k].push(cap);
|
||
}
|
||
return groups;
|
||
}, [data]);
|
||
|
||
const toolSkillColumns = registeredColumns(isOwner, busy, setPolicy, uninstall);
|
||
const channelCols = channelColumns();
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<PageHeader
|
||
title="插件与能力"
|
||
subtitle="已注册能力(按工具 / 技能 / 渠道分组)+ 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)"
|
||
/>
|
||
|
||
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
|
||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||
{msg ? <Notice tone="ok">{msg}</Notice> : null}
|
||
|
||
<InstalledPluginsPanel />
|
||
|
||
<Surface className="space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="text-sm font-semibold">已注册能力</h3>
|
||
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 按工具 / 技能 / 渠道分组</span>
|
||
</div>
|
||
|
||
{(["tool", "skill"] as CapKind[]).map((kind) => {
|
||
const rows = byKind[kind];
|
||
return (
|
||
<div key={kind} className="space-y-1.5">
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<Tag tone="accent">{KIND_LABELS[kind]}</Tag>
|
||
<span className="text-xs text-[var(--mu-text-lo)]">{rows.length} 个</span>
|
||
</div>
|
||
<AdminTable
|
||
columns={toolSkillColumns}
|
||
rows={loading ? null : rows}
|
||
loading={loading}
|
||
error={error}
|
||
emptyTitle={`无${KIND_LABELS[kind]}`}
|
||
rowKey={(p) => `${kind}:${p.name}`}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className="space-y-1.5">
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<Tag tone="neutral">{KIND_LABELS.channel}</Tag>
|
||
<span className="text-xs text-[var(--mu-text-lo)]">{byKind.channel.length} 个</span>
|
||
</div>
|
||
<AdminTable
|
||
columns={channelCols}
|
||
rows={loading ? null : byKind.channel}
|
||
loading={loading}
|
||
error={error}
|
||
emptyTitle="无渠道"
|
||
rowKey={(p) => `channel:${p.name}`}
|
||
/>
|
||
</div>
|
||
</Surface>
|
||
|
||
<Notice>工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。</Notice>
|
||
</div>
|
||
);
|
||
}
|