feat(admin): 面板补全 — 插件安装表单+已装列表 / LLM组maxTokens展示编辑 / POST admin/llm 部分编辑合并
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 前端: api.ts(adminPluginInstall带config/uninstall/listInstalledPlugins) + types + plugins/InstalledPlugins.tsx(已装列表+安装表单, Miuix) + llm/LlmProviderForm.tsx(maxTokens输入) ; Plugins.tsx去假安装按钮
- 后端 admin_write_llm_channels.go: llmProviderRequest字段改指针, mergeLlmProvider部分合并(省略保留原值), runtimeconfig/models.go Find
- 新增测试 admin_write_test TestAdminAddLlmProviderPartialEditPreservesFields
- 前端 build + 后端 build/vet/test 全绿; Docs/api-client v0.29
- (面板无法实机登录, 用 react-dom/server 冒烟渲染 LlmModule/PluginsModule)
This commit is contained in:
AskaEth committed 2026-09-08 23:47:09 +08:00
1 parent 70c9fba529
commit 18b2040c95
10 files changed
+812 -220

No files matched your search

+52 -89
View File
@@ -1,56 +1,64 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, adminAddLlmProvider, adminTestLlm, isNotImplemented } from "../../lib/api";
import { adminGetLlm, adminTestLlm, isNotImplemented } from "../../lib/api";
import type { AdminLlmProvider, AdminLlmConfig } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { Button, Surface, Tag } from "../../components/primitives";
import { LlmProviderForm } from "./llm/LlmProviderForm";
const sample: AdminLlmConfig = {
providers: [
{ id: "p1", name: "主模型", provider: "openai", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true },
{ id: "p1", name: "主模型", provider: "openai", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true, maxTokens: 8192 },
{ id: "p2", name: "备选模型", provider: "openai", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true },
],
};
/** One provider group row: identity + current token budget + test/edit actions. */
function ProviderRow({
p,
busy,
onTest,
onEdit,
}: {
p: AdminLlmProvider;
busy: boolean;
onTest: (p: AdminLlmProvider) => void;
onEdit: (p: AdminLlmProvider) => void;
}): React.ReactNode {
return (
<div className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<span className="font-medium">{p.name}</span>
<Tag tone="neutral">{p.model}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.baseUrl || "—"}</span>
<Tag tone={p.maxTokens ? "accent" : "neutral"}>
{p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"}
</Tag>
<span className="ml-auto flex items-center gap-4">
<Tag tone={p.enabled ? "ok" : "neutral"}>{p.enabled ? "启用" : "停用"}</Tag>
<Tag tone={p.multimodal ? "accent" : "neutral"}>{p.multimodal ? "多模态" : "文本"}</Tag>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onEdit(p)}>
编辑
</Button>
<Button size="sm" variant="ghost" disabled={busy || !p.baseUrl} onClick={() => onTest(p)}>
测试
</Button>
</span>
</div>
);
}
export function LlmModule(): React.ReactNode {
const { data, error, reload } = useAsyncData(adminGetLlm, []);
const log = useAuditStore((s) => s.log);
const backend = !isNotImplemented(error);
const providers = backend && data ? data.providers : sample.providers;
const [name, setName] = useState("");
const [provider, setProvider] = useState("openai");
const [model, setModel] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [multimodal, setMultimodal] = useState(false);
const [editing, setEditing] = useState<AdminLlmProvider | null>(null);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const addProvider = () => {
setBusy(true);
setFail(null);
setResult(null);
adminAddLlmProvider({ id: name || undefined, provider, baseUrl, apiKey, model, multimodal })
.then((r) => {
log("llm.addProvider", "ok");
setResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${r.path ? `(${r.path})` : ""}`);
setName("");
setModel("");
setBaseUrl("");
setApiKey("");
setMultimodal(false);
reload();
})
.catch((e: unknown) => {
log("llm.addProvider", "error");
setFail(e instanceof Error ? e.message : "新增模型失败");
})
.finally(() => setBusy(false));
};
const test = (p: AdminLlmProvider) => {
setBusy(true);
setFail(null);
@@ -72,12 +80,7 @@ export function LlmModule(): React.ReactNode {
<div className="space-y-5">
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试(真实调用) + fallback + 按角色路由 + 用量"
actions={
<Button variant="primary" disabled={busy || !model || !baseUrl} onClick={addProvider}>
保存模型
</Button>
}
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
/>
{backend && !error ? <Notice tone="ok">后端已返回 LLM 配置(数据以接口为准)。</Notice> : null}
@@ -89,57 +92,20 @@ export function LlmModule(): React.ReactNode {
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
{providers.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.model}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.baseUrl || "—"}</span>
<span className="ml-auto flex items-center gap-4">
<Tag tone={r.enabled ? "ok" : "neutral"}>{r.enabled ? "启用" : "停用"}</Tag>
<Tag tone={r.multimodal ? "accent" : "neutral"}>{r.multimodal ? "多模态" : "文本"}</Tag>
<Button size="sm" variant="ghost" disabled={busy || !r.baseUrl} onClick={() => test(r)}>
测试
</Button>
</span>
</div>
<ProviderRow key={r.id} p={r} busy={busy} onTest={test} onEdit={setEditing} />
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">新增模型(保存到 models.json,即刻热更新)</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称 / ID(留空自动生成)</span>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="deepseek" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">协议 Provider</span>
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={inputCls}>
<option value="openai">openai(OpenAI 兼容)</option>
<option value="anthropic">anthropic(Anthropic Messages)</option>
</select>
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">模型</span>
<input value={model} onChange={(e) => setModel(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL</span>
<input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">API key(写入 models.json,不回显)</span>
<input value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-…" className={inputCls} />
</label>
<div className="flex items-end gap-2 pb-[2px]">
<Switch checked={multimodal} onChange={setMultimodal} label="多模态" />
</div>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">保存后写入 models.json(本地、已 gitignore),并热更新运行中的 LLM provider,无需重启进程。API key 不在列表/响应中回显。</p>
</Surface>
<LlmProviderForm
key={editing?.id ?? "new"}
initial={editing}
busy={busy}
setBusy={setBusy}
onResult={setResult}
onError={setFail}
onSaved={reload}
onCancel={() => setEditing(null)}
/>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
@@ -152,10 +118,7 @@ export function LlmModule(): React.ReactNode {
))}
</Surface>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。</Notice>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。</Notice>
</div>
);
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
+63 -68
View File
@@ -1,71 +1,21 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPlugins, adminSetPluginPolicy, adminPluginInstall, adminPluginUninstall, getStoredRole } from "../../lib/api";
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";
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 install = () => {
setBusy("__install__");
setFail(null);
setMsg(null);
adminPluginInstall({ name: "sample-plugin", kind: "tool", description: "手工安装示例" })
.then(() => {
log("plugin.install", "ok");
setMsg("插件安装成功");
reload();
})
.catch((err: unknown) => {
log("plugin.install", "error");
setFail(err instanceof Error ? err.message : "插件安装失败");
})
.finally(() => setBusy(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 columns: AdminColumn<AdminPluginRecord>[] = [
/** Columns for the registered-capability table (GET /admin/plugins). */
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">{p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
@@ -104,24 +54,69 @@ export function PluginsModule(): React.ReactNode {
),
},
];
}
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 columns = registeredColumns(isOwner, busy, setPolicy, uninstall);
return (
<div className="space-y-5">
<PageHeader
title="插件与能力"
subtitle="GET /api/v1/admin/plugins · 启停/安装/卸载/MCP 连接 · POST /api/v1/admin/plugins/policy (owner-only)"
actions={
<Button variant="primary" disabled={busy !== null} onClick={install}>
安装插件
</Button>
}
subtitle="GET /api/v1/admin/plugins · 已注册能力 + 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)"
/>
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略仅 owner 可写(读取/查看可见)。</Notice> : null}
{!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-2">
<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>
<AdminTable
columns={columns}
rows={loading ? null : data}
@@ -132,7 +127,7 @@ export function PluginsModule(): React.ReactNode {
/>
</Surface>
<Notice>工具 schema / 技能 / MCP 服务器列表为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。</Notice>
<Notice>工具 schema / 技能为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。</Notice>
</div>
);
}
@@ -0,0 +1,172 @@
import { useState } from "react";
import { adminAddLlmProvider } from "../../../lib/api";
import type { AdminLlmProvider, LlmProviderRequest } from "../../../lib/types";
import { useAuditStore } from "../../audit";
import { Button, Surface, Switch } from "../../../components/primitives";
/** Add/edit form for one LLM provider group. The group list hands in an
* `initial` group to edit (remounted via key) or null to add a new one. The
* backend merges the request over the stored group, so blank optional fields
* (apiKey / maxTokens) are simply omitted and keep their stored value. */
interface FormState {
name: string;
provider: string;
model: string;
baseUrl: string;
apiKey: string;
multimodal: boolean;
maxTokens: string;
}
type FieldSetter = <K extends keyof FormState>(key: K, value: FormState[K]) => void;
function initState(initial: AdminLlmProvider | null): FormState {
return {
name: initial?.id ?? "",
provider: initial?.provider || "openai",
model: initial?.model ?? "",
baseUrl: initial?.baseUrl ?? "",
apiKey: "",
multimodal: initial?.multimodal ?? false,
maxTokens: initial?.maxTokens ? String(initial.maxTokens) : "",
};
}
/** The form fields; state lives in the form component below. */
function ProviderFields({ form, set, editing }: { form: FormState; set: FieldSetter; editing: boolean }): React.ReactNode {
return (
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称 / ID(留空自动生成)</span>
<input
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="deepseek"
disabled={editing}
className={inputCls}
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">协议 Provider</span>
<select value={form.provider} onChange={(e) => set("provider", e.target.value)} className={inputCls}>
<option value="openai">openai(OpenAI 兼容)</option>
<option value="anthropic">anthropic(Anthropic Messages)</option>
</select>
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">模型</span>
<input value={form.model} onChange={(e) => set("model", e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL</span>
<input value={form.baseUrl} onChange={(e) => set("baseUrl", e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">
API key({editing ? "留空 = 保留原 key" : "写入 models.json,不回显"})
</span>
<input value={form.apiKey} onChange={(e) => set("apiKey", e.target.value)} placeholder="sk-…" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">maxTokens(该组预算,留空 = 保持/默认)</span>
<input
value={form.maxTokens}
onChange={(e) => set("maxTokens", e.target.value)}
inputMode="numeric"
placeholder="8192"
className={inputCls}
/>
</label>
</div>
<div className="flex items-end gap-2 pb-[2px]">
<Switch checked={form.multimodal} onChange={(v) => set("multimodal", v)} label="多模态" />
</div>
</>
);
}
interface LlmProviderFormProps {
initial: AdminLlmProvider | null;
busy: boolean;
setBusy: (v: boolean) => void;
onResult: (msg: string) => void;
onError: (msg: string | null) => void;
onSaved: () => void;
onCancel: () => void;
}
/** Owns the field state and the save round-trip; the module supplies callbacks. */
export function LlmProviderForm({
initial,
busy,
setBusy,
onResult,
onError,
onSaved,
onCancel,
}: LlmProviderFormProps): React.ReactNode {
const editing = initial !== null;
const [form, setForm] = useState<FormState>(() => initState(initial));
const log = useAuditStore((s) => s.log);
const set: FieldSetter = (key, value) => setForm((prev) => ({ ...prev, [key]: value }));
const save = () => {
const budget = form.maxTokens.trim();
if (budget !== "" && !/^\d+$/.test(budget)) {
onError("maxTokens 必须为非负整数(留空 = 保持/默认)");
return;
}
setBusy(true);
onError(null);
const req: LlmProviderRequest = { provider: form.provider, baseUrl: form.baseUrl, model: form.model, multimodal: form.multimodal };
if (editing && initial) req.id = initial.id;
else if (form.name.trim()) req.id = form.name.trim();
if (form.apiKey.trim()) req.apiKey = form.apiKey.trim();
if (budget !== "") req.maxTokens = Number(budget);
adminAddLlmProvider(req)
.then((r) => {
log("llm.addProvider", "ok");
const budgetNote = r.provider.maxTokens ? ` · maxTokens ${r.provider.maxTokens}` : "";
onResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${budgetNote}${r.path ? `(${r.path})` : ""}`);
onSaved();
if (editing) onCancel();
else setForm(initState(null));
})
.catch((e: unknown) => {
log("llm.addProvider", "error");
onError(e instanceof Error ? e.message : "保存模型失败");
})
.finally(() => setBusy(false));
};
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{editing ? `编辑模型组 · ${initial?.id}` : "新增模型(保存到 models.json,即刻热更新)"}</h3>
{editing ? (
<Button size="sm" variant="ghost" className="ml-auto" onClick={onCancel}>
取消编辑
</Button>
) : null}
</div>
<ProviderFields form={form} set={set} editing={editing} />
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || !form.model || !form.baseUrl} onClick={save}>
{editing ? "保存修改" : "保存模型"}
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">
{editing ? "只提交本表单填写的字段,未填字段保持原值" : "保存后写入 models.json 并热更新运行中的 LLM provider"}
</span>
</div>
</Surface>
);
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60";
@@ -0,0 +1,306 @@
import { useState } from "react";
import { useAsyncData } from "../../../lib/useAsyncData";
import {
adminListInstalledPlugins,
adminPluginInstall,
adminPluginUninstall,
getStoredRole,
} from "../../../lib/api";
import type { InstalledPlugin, PluginInstallConfig } from "../../../lib/types";
import { useAutoReload } from "../../hooks";
import { useAuditStore } from "../../audit";
import { AdminTable, Notice, type AdminColumn } from "../../components";
import { Button, Surface, Tag } from "../../../components/primitives";
/** Runtime-installed plugin (MCP server) section: the live list plus the install
* form. Kept out of Plugins.tsx so the module stays under the 500-line bound.
* Installing an MCP server hot-registers its tools; the list shows live/toolCount. */
const TRANSPORTS: ReadonlyArray<{ value: string; label: string }> = [
{ value: "stdio", label: "stdio(本地子进程)" },
{ value: "http", label: "http(远程 endpoint)" },
{ value: "sse", label: "sse(远程 endpoint)" },
];
interface FormState {
kind: string;
name: string;
transport: string;
endpoint: string;
command: string;
args: string;
apiKey: string;
env: string;
}
const EMPTY_FORM: FormState = {
kind: "mcp",
name: "",
transport: "stdio",
endpoint: "",
command: "",
args: "",
apiKey: "",
env: "",
};
/** Parses a multiline `KEY=VALUE` block into an env map, skipping blanks. */
function parseEnv(raw: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of raw.split("\n")) {
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (key) out[key] = line.slice(eq + 1).trim();
}
return out;
}
/** Parses whitespace-separated args into the stdio child argv. */
function parseArgs(raw: string): string[] {
return raw.split(/\s+/).map((s) => s.trim()).filter(Boolean);
}
/** Builds the install config for the selected transport, omitting blank fields. */
function buildConfig(f: FormState): PluginInstallConfig {
const config: PluginInstallConfig = { transport: f.transport };
if (f.transport === "stdio") {
config.command = f.command.trim();
const args = parseArgs(f.args);
if (args.length > 0) config.args = args;
} else {
config.endpoint = f.endpoint.trim();
}
if (f.apiKey.trim()) config.apiKey = f.apiKey.trim();
const env = parseEnv(f.env);
if (Object.keys(env).length > 0) config.env = env;
return config;
}
/** Client-side guard mirroring the backend's required-field checks. */
function formError(f: FormState): string | null {
if (!f.name.trim()) return "请填写插件名称";
if (f.transport === "stdio" && !f.command.trim()) return "stdio 传输必须填写 command";
if (f.transport !== "stdio" && !f.endpoint.trim()) return "http/sse 传输必须填写 endpoint";
return null;
}
type FieldSetter = <K extends keyof FormState>(key: K, value: FormState[K]) => void;
/** Presentational install-form fields; the form component owns the state. */
function InstallFormFields({ form, set }: { form: FormState; set: FieldSetter }): React.ReactNode {
const isStdio = form.transport === "stdio";
return (
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">类型(v1 仅 MCP server)</span>
<select value={form.kind} onChange={(e) => set("kind", e.target.value)} className={inputCls}>
<option value="mcp">mcp</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称(同名重复安装 = 刷新)</span>
<input value={form.name} onChange={(e) => set("name", e.target.value)} placeholder="filesystem" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">传输</span>
<select value={form.transport} onChange={(e) => set("transport", e.target.value)} className={inputCls}>
{TRANSPORTS.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</label>
</div>
{isStdio ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">command</span>
<input value={form.command} onChange={(e) => set("command", e.target.value)} placeholder="npx" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">args(空格分隔)</span>
<input value={form.args} onChange={(e) => set("args", e.target.value)} placeholder="-y @modelcontextprotocol/server-filesystem /tmp" className={inputCls} />
</label>
</div>
) : (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">endpoint</span>
<input value={form.endpoint} onChange={(e) => set("endpoint", e.target.value)} placeholder="https://mcp.example.com/sse" className={inputCls} />
</label>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">API key(http/sse 鉴权,不回显)</span>
<input value={form.apiKey} onChange={(e) => set("apiKey", e.target.value)} placeholder="sk-…" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">env(每行 KEY=VALUE,stdio 子进程环境)</span>
<textarea value={form.env} onChange={(e) => set("env", e.target.value)} rows={2} className={inputCls} />
</label>
</div>
</>
);
}
interface InstallFormProps {
busy: boolean;
owner: boolean;
onDone: (msg: string) => void;
onError: (msg: string | null) => void;
}
/** The MCP install form: owns field state, validates, then installs + refreshes. */
function PluginInstallForm({ busy, owner, onDone, onError }: InstallFormProps): React.ReactNode {
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [submitting, setSubmitting] = useState(false);
const log = useAuditStore((s) => s.log);
const set: FieldSetter = (key, value) => setForm((prev) => ({ ...prev, [key]: value }));
const submit = () => {
const invalid = formError(form);
if (invalid) {
onError(invalid);
return;
}
setSubmitting(true);
onError(null);
adminPluginInstall({ kind: form.kind, name: form.name.trim(), config: buildConfig(form) })
.then((res) => {
log("plugin.install", "ok");
onDone(`已安装 ${res.name}(${res.transport})· 注册工具 ${res.toolCount} 个`);
setForm(EMPTY_FORM);
})
.catch((err: unknown) => {
log("plugin.install", "error");
onError(err instanceof Error ? err.message : "插件安装失败");
})
.finally(() => setSubmitting(false));
};
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">安装 MCP 插件</h3>
<InstallFormFields form={form} set={set} />
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || submitting || !owner} onClick={submit}>
{submitting ? "安装中…" : "安装 / 刷新"}
</Button>
{!owner ? <span className="text-xs text-[var(--mu-text-lo)]">仅 owner 可安装</span> : null}
</div>
</Surface>
);
}
/** Table columns for the installed-plugin list. */
function installedColumns(
isOwner: boolean,
busy: boolean,
uninstall: (name: string) => void,
): AdminColumn<InstalledPlugin>[] {
return [
{
key: "name",
title: "名称",
render: (p) => (
<span className="flex items-center gap-2">
<span className="font-medium">{p.name}</span>
<Tag tone="accent">{p.kind}</Tag>
</span>
),
},
{ key: "transport", title: "传输", render: (p) => <Tag tone="neutral">{p.transport || "—"}</Tag> },
{
key: "target",
title: "连接目标",
render: (p) => (
<span className="block max-w-xs truncate font-mono text-xs text-[var(--mu-text-mid)]">
{p.endpoint || [p.command, ...(p.args ?? [])].filter(Boolean).join(" ") || "—"}
</span>
),
},
{ key: "tools", title: "工具数", render: (p) => <span className="tabular-nums">{p.toolCount}</span> },
{
key: "live",
title: "状态",
render: (p) => <Tag tone={p.live ? "ok" : "warn"}>{p.live ? "已连接" : "未连接"}</Tag>,
},
{
key: "creds",
title: "凭据(掩码)",
render: (p) => (
<span className="block text-xs text-[var(--mu-text-lo)]">
{p.apiKey ? <span className="font-mono">{p.apiKey}</span> : null}
{p.env && Object.keys(p.env).length > 0 ? <span className="ml-2">env: {Object.keys(p.env).join(", ")}</span> : null}
{!p.apiKey && (!p.env || Object.keys(p.env).length === 0) ? "—" : null}
</span>
),
},
{
key: "actions",
title: "操作",
render: (p) => (
<Button size="sm" variant="ghost" disabled={!isOwner || busy} onClick={() => uninstall(p.name)}>
卸载
</Button>
),
},
];
}
/** Installed-plugin list + install form, with its own refresh/feedback state. */
export function InstalledPluginsPanel(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListInstalledPlugins, []);
useAutoReload(reload, 10000);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const uninstall = (name: string) => {
setBusy(true);
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(false));
};
return (
<div className="space-y-5">
<Surface className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">已装插件(运行时 MCP)</h3>
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins/installed</span>
<Button size="sm" variant="ghost" className="ml-auto" disabled={loading} onClick={reload}>
刷新
</Button>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{msg ? <Notice tone="ok">{msg}</Notice> : null}
<AdminTable
columns={installedColumns(isOwner, busy, uninstall)}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="尚未安装任何运行时插件"
rowKey={(p) => p.id || p.name}
/>
</Surface>
<PluginInstallForm busy={busy} owner={isOwner} onDone={setMsg} onError={setFail} />
</div>
);
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";