From 18b2040c95bf60d22c7a71b2ce7eb9ccf5665742 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 8 Sep 2026 23:47:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E9=9D=A2=E6=9D=BF=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=20=E2=80=94=20=E6=8F=92=E4=BB=B6=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E8=A1=A8=E5=8D=95+=E5=B7=B2=E8=A3=85=E5=88=97=E8=A1=A8=20/=20L?= =?UTF-8?q?LM=E7=BB=84maxTokens=E5=B1=95=E7=A4=BA=E7=BC=96=E8=BE=91=20/=20?= =?UTF-8?q?POST=20admin/llm=20=E9=83=A8=E5=88=86=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端: 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) --- Docs/api-client.md | 1 + frontend/web/src/admin/modules/Llm.tsx | 141 +++----- frontend/web/src/admin/modules/Plugins.tsx | 131 ++++---- .../src/admin/modules/llm/LlmProviderForm.tsx | 172 ++++++++++ .../modules/plugins/InstalledPlugins.tsx | 306 ++++++++++++++++++ frontend/web/src/lib/api.ts | 25 +- frontend/web/src/lib/types.ts | 56 +++- internal/config/runtimeconfig/models.go | 15 + .../gateway/admin_write_llm_channels.go | 141 ++++---- internal/services/gateway/admin_write_test.go | 44 +++ 10 files changed, 812 insertions(+), 220 deletions(-) create mode 100644 frontend/web/src/admin/modules/llm/LlmProviderForm.tsx create mode 100644 frontend/web/src/admin/modules/plugins/InstalledPlugins.tsx diff --git a/Docs/api-client.md b/Docs/api-client.md index ae0b5b9..93556d2 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -216,6 +216,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 +- `v0.29`(**/admin/llm 读-改-写合并**):`POST /api/v1/admin/llm` 由「全量替换」改为**部分编辑合并**——请求体字段用指针区分「未给」与「显式零值」;`maxTokens` / `apiKey` / `provider` / `baseUrl` / `model` / `fallback` / `multimodal` **省略即保留原值**(不再把省略的 `maxTokens` 重置为 0),新组仍要求 `baseUrl`+`model`、非法 provider / 负 maxTokens 仍 `400`。面板 LLM 页新增每组 `maxTokens` 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 `config`(修掉旧 body 400)。`runtimeconfig/models.go` 新增 `Find`。 - `v0.28`(**MCP 插件热安装/卸载**):`POST /api/v1/admin/plugins/install`(**由 501 改为真实实现**,owner-only + 审计)body `{kind:"mcp",name,config:{transport,endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed,name,kind,transport,toolCount}}`——真实连接 MCP server、发现并**热注册工具**进 capability registry(下一轮模型请求即注入),配置落盘 `plugins.json`(gitignore、0600);连接失败回 `502` 且**不落盘**;同名重复 = 刷新。`POST /api/v1/admin/plugins/uninstall` 扩展:MCP 插件走 `Unregister`(移除工具 + close + 删配置),普通能力保持原 `registry.Remove`。新增 `GET /api/v1/admin/plugins/installed`(owner/admin 读)列出已装插件(`apiKey`/敏感 `env` 掩码 + `live`/`toolCount`)。`mcp.MCPConfig` 新增 `APIKey`(http/sse 以 `Authorization: Bearer` 发送)与 `Env`(stdio 子进程环境);`plugins.json` 于启动时 `Restore` 重新安装。**契约版本 `v0.28`**。 - `v0.27`(**LLM token 预算可配置化**):`models.json` 模型组新增可选 **`maxTokens`**(`int`,缺省/`0` = 未设),作为该组主/备链的 token 预算;`GET /api/v1/admin/llm` 的 `AdminLlmProvider` 与 `POST /api/v1/admin/llm` body/回显同步该字段(`omitempty`,未设为 0 时不出现)。`.env` 新增 **`LLM_FALLBACK_MAX_TOKENS`**(`int`,默认 `8192`,`0` = 关闭兜底),只作用于**确有回退模型**时链末端的回退步。**优先级**:显式 `Request.MaxTokens`(审查/判定类小预算)> 生效组 `maxTokens` > `LLM_FALLBACK_MAX_TOKENS`(仅链末端、且组未设时)> 适配器默认(anthropic 8192)。单步链(无 fallback)不套用 env 兜底。`maxTokens` 为负回 `400 bad_request`。**契约版本 `v0.27`**。 - `v0.26`(**A5 消息正向增量**):`GET /api/v1/sessions/{id}/messages` 新增 **`after`** 查询参数(``,升序返回 `created_at > after` 的消息,默认 `limit=50`/上限 200)——解决「WS 重连只回放主会话最近 50 条、断线超 50 条永久丢」的缺口;`before` 现行为不变;**`after` 与 `before` 互斥**(同传 `400`);负数/非数字 `400`。`HistoryStore` 新增 `ListAfter`(走 `idx_chat_msgs_session_created`,`rowid` 兜底同毫秒并列)。**游标契约**:客户端用「本页最后一条 `createdAt`」作下一页 `after`;同毫秒消息 > limit 时纯 `createdAt` 游标会跨页重读(超罕见,客户端按 `id` 去重兜底,与现有绕行一致)。 diff --git a/frontend/web/src/admin/modules/Llm.tsx b/frontend/web/src/admin/modules/Llm.tsx index 73224bd..8d90218 100644 --- a/frontend/web/src/admin/modules/Llm.tsx +++ b/frontend/web/src/admin/modules/Llm.tsx @@ -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 ( +
+ {p.name} + {p.model} + {p.baseUrl || "—"} + + {p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"} + + + {p.enabled ? "启用" : "停用"} + {p.multimodal ? "多模态" : "文本"} + + + +
+ ); +} + 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(null); const [busy, setBusy] = useState(false); const [fail, setFail] = useState(null); const [result, setResult] = useState(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 {
- 保存模型 - - } + subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量" /> {backend && !error ? 后端已返回 LLM 配置(数据以接口为准)。 : null} @@ -89,57 +92,20 @@ export function LlmModule(): React.ReactNode {

供应商 / 模型

{providers.map((r) => ( -
- {r.name} - {r.model} - {r.baseUrl || "—"} - - {r.enabled ? "启用" : "停用"} - {r.multimodal ? "多模态" : "文本"} - - -
+ ))}
- -

新增模型(保存到 models.json,即刻热更新)

-
- - -
-
- - -
-
- -
- -
-
-

保存后写入 models.json(本地、已 gitignore),并热更新运行中的 LLM provider,无需重启进程。API key 不在列表/响应中回显。

-
+ setEditing(null)} + />

按角色路由 + 降级链

@@ -152,10 +118,7 @@ export function LlmModule(): React.ReactNode { ))}
- per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。 + per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。
); } - -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)]"; diff --git a/frontend/web/src/admin/modules/Plugins.tsx b/frontend/web/src/admin/modules/Plugins.tsx index c24f522..e3d1d3b 100644 --- a/frontend/web/src/admin/modules/Plugins.tsx +++ b/frontend/web/src/admin/modules/Plugins.tsx @@ -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(null); - const [fail, setFail] = useState(null); - const [msg, setMsg] = useState(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[] = [ +/** 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[] { + return [ { key: "name", title: "名称", render: (p) => {p.name} }, { key: "kind", title: "类型", render: (p) => {p.kind} }, { key: "desc", title: "描述", render: (p) => {p.description || "—"} }, @@ -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(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 columns = registeredColumns(isOwner, busy, setPolicy, uninstall); return (
- 安装插件 - - } + subtitle="GET /api/v1/admin/plugins · 已注册能力 + 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)" /> - {!isOwner ? 当前角色为 {getStoredRole()},工具策略仅 owner 可写(读取/查看可见)。 : null} + {!isOwner ? 当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。 : null} {fail ? {fail} : null} {msg ? {msg} : null} + + +
+

已注册能力

+ GET /api/v1/admin/plugins +
- 工具 schema / 技能 / MCP 服务器列表为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。 + 工具 schema / 技能为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。
); } diff --git a/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx b/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx new file mode 100644 index 0000000..5576448 --- /dev/null +++ b/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx @@ -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 = (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 ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ set("multimodal", v)} label="多模态" /> +
+ + ); +} + +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(() => 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 ( + +
+

{editing ? `编辑模型组 · ${initial?.id}` : "新增模型(保存到 models.json,即刻热更新)"}

+ {editing ? ( + + ) : null} +
+ +
+ + + {editing ? "只提交本表单填写的字段,未填字段保持原值" : "保存后写入 models.json 并热更新运行中的 LLM provider"} + +
+
+ ); +} + +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"; diff --git a/frontend/web/src/admin/modules/plugins/InstalledPlugins.tsx b/frontend/web/src/admin/modules/plugins/InstalledPlugins.tsx new file mode 100644 index 0000000..06facc3 --- /dev/null +++ b/frontend/web/src/admin/modules/plugins/InstalledPlugins.tsx @@ -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 { + const out: Record = {}; + 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 = (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 ( + <> +
+ + + +
+ {isStdio ? ( +
+ + +
+ ) : ( + + )} +
+ +