feat(admin): 面板补全 — 插件安装表单+已装列表 / LLM组maxTokens展示编辑 / POST admin/llm 部分编辑合并
- 前端: 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:
1 parent
70c9fba529
commit
18b2040c95
10 files changed
+812
-220
No files matched your search
@@ -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`** 查询参数(`<createdAt 毫秒>`,升序返回 `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` 去重兜底,与现有绕行一致)。
|
||||
|
||||
@@ -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)]";
|
||||
@@ -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)]";
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ChannelTestResult,
|
||||
ChatMessage,
|
||||
ChatSyncResult,
|
||||
InstalledPlugin,
|
||||
LlmProviderRequest,
|
||||
LlmTestRequest,
|
||||
LlmTestResult,
|
||||
@@ -32,6 +33,8 @@ import type {
|
||||
Mode,
|
||||
PersonaRecord,
|
||||
PersonaUpsertRequest,
|
||||
PluginInstallRequest,
|
||||
PluginInstallResult,
|
||||
KookFriendList,
|
||||
KookMessageTestResult,
|
||||
KookViewResponse,
|
||||
@@ -391,6 +394,11 @@ export function adminListMemories(): Promise<MemoryRecord[]> {
|
||||
export function adminListPlugins(): Promise<AdminPluginRecord[]> {
|
||||
return request<AdminPluginRecord[]>("/admin/plugins");
|
||||
}
|
||||
/** GET /api/v1/admin/plugins/installed — runtime-installed plugins (v0.28), with
|
||||
* the backend masking apiKey/sensitive env and reporting live/toolCount. */
|
||||
export function adminListInstalledPlugins(): Promise<InstalledPlugin[]> {
|
||||
return request<InstalledPlugin[]>("/admin/plugins/installed");
|
||||
}
|
||||
/** POST /api/v1/admin/plugins/policy — owner-only, audited. */
|
||||
export function adminSetPluginPolicy(
|
||||
name: string,
|
||||
@@ -677,15 +685,18 @@ export function adminWeixinLoginStatus(
|
||||
`/admin/channels/${encodeURIComponent(platform)}/login-status${q}`,
|
||||
);
|
||||
}
|
||||
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
|
||||
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
|
||||
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
|
||||
/** POST /api/v1/admin/plugins/install — installs (or refreshes) an MCP server
|
||||
* plugin (v0.28): connects, discovers its tools and hot-registers them. The
|
||||
* backend answers 502 and persists nothing when connect/discover fails. */
|
||||
export function adminPluginInstall(req: PluginInstallRequest): Promise<PluginInstallResult> {
|
||||
return request<PluginInstallResult>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
|
||||
}
|
||||
/** POST /api/v1/admin/plugins/uninstall — removes a capability from the registry. */
|
||||
export function adminPluginUninstall(req: { name: string }): Promise<{ name: string; uninstalled: boolean }> {
|
||||
return request<{ name: string; uninstalled: boolean }>("/admin/plugins/uninstall", {
|
||||
/** POST /api/v1/admin/plugins/uninstall — removes an installed MCP server (tools
|
||||
* unregistered + config deleted) or a plain capability from the registry. */
|
||||
export function adminPluginUninstall(name: string): Promise<{ name: string; uninstalled: boolean; kind?: string }> {
|
||||
return request<{ name: string; uninstalled: boolean; kind?: string }>("/admin/plugins/uninstall", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/memories/search — real retrieval over the memory store. */
|
||||
|
||||
@@ -261,6 +261,25 @@ export interface AdminPluginRecord {
|
||||
allowInStranger: boolean;
|
||||
}
|
||||
|
||||
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
|
||||
* A v1 plugin is an external MCP server; `live` = connected and its tools are
|
||||
* registered, `toolCount` = tools it contributed. The backend masks apiKey and
|
||||
* sensitive env values before they reach the client. */
|
||||
export interface InstalledPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
transport: string;
|
||||
endpoint?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
createdAt?: string;
|
||||
live: boolean;
|
||||
toolCount: number;
|
||||
}
|
||||
|
||||
/** A channel adapter as returned by GET /api/v1/admin/channels. */
|
||||
export interface AdminChannelRecord {
|
||||
name: string;
|
||||
@@ -355,6 +374,9 @@ export interface AdminLlmProvider {
|
||||
multimodal: boolean;
|
||||
enabled: boolean;
|
||||
fallback?: string;
|
||||
/** The group's token budget. Absent/0 = unset (the runtime falls back to
|
||||
* LLM_FALLBACK_MAX_TOKENS on the last step, then the adapter default). */
|
||||
maxTokens?: number;
|
||||
}
|
||||
export interface AdminLlmConfig {
|
||||
providers: AdminLlmProvider[];
|
||||
@@ -508,7 +530,9 @@ export interface AutomationRunResult {
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/llm body — saves a provider group to models.json and
|
||||
* hot-reloads the running LLM stack (persisted + active). */
|
||||
* hot-reloads the running LLM stack (persisted + active). The backend merges
|
||||
* the request over the stored group (read-modify-write): an omitted field keeps
|
||||
* its stored value, so a partial edit never wipes a budget or key. */
|
||||
export interface LlmProviderRequest {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -518,6 +542,8 @@ export interface LlmProviderRequest {
|
||||
model: string;
|
||||
multimodal?: boolean;
|
||||
fallback?: string;
|
||||
/** Absent = keep the stored budget (or leave unset for a new group). */
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/llm/test body. */
|
||||
@@ -538,6 +564,34 @@ export interface LlmTestResult {
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
/** MCP server connection block for POST /api/v1/admin/plugins/install (v0.28).
|
||||
* `endpoint` is for http/sse, `command` (+ `args`) for stdio. apiKey is sent
|
||||
* once and persisted but never echoed back; env is the stdio child environment. */
|
||||
export interface PluginInstallConfig {
|
||||
transport: string;
|
||||
endpoint?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/plugins/install body. v1 installs MCP servers only. */
|
||||
export interface PluginInstallRequest {
|
||||
kind: string;
|
||||
name: string;
|
||||
config: PluginInstallConfig;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/plugins/install result — the live server's registered tool count. */
|
||||
export interface PluginInstallResult {
|
||||
installed: boolean;
|
||||
name: string;
|
||||
kind: string;
|
||||
transport: string;
|
||||
toolCount: number;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/channels/test result (structural identity verdict). */
|
||||
export interface ChannelTestResult {
|
||||
role: string;
|
||||
|
||||
@@ -184,6 +184,21 @@ func GroupFromEnv(env config.LLMConfig) []Group {
|
||||
}}
|
||||
}
|
||||
|
||||
// Find returns the group with the given id, if present. It is the read half of
|
||||
// the admin read-modify-write merge, so a partial edit starts from the stored
|
||||
// group instead of zero values.
|
||||
func Find(groups []Group, id string) (Group, bool) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return Group{}, false
|
||||
}
|
||||
for _, g := range groups {
|
||||
if g.ID == id {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return Group{}, false
|
||||
}
|
||||
|
||||
// Upsert returns a copy of groups with g inserted or replaced in place by ID,
|
||||
// preserving order. A blank ID is assigned a generated id so a panel save
|
||||
// without an explicit id still produces a stable, addressable group.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -36,74 +37,55 @@ const (
|
||||
)
|
||||
|
||||
// llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a
|
||||
// provider group. Name is a client-side display label (not persisted — the
|
||||
// models.json schema carries id/provider/baseUrl/apiKey/model/fallback/
|
||||
// multimodal/maxTokens).
|
||||
// provider group. Every field is a pointer so the handler can tell "absent"
|
||||
// (preserve the stored value) from "explicitly zero" (overwrite): a partial edit
|
||||
// from the panel must never wipe a field it did not touch. Name is a client-side
|
||||
// display label (not persisted — the models.json schema carries id/provider/
|
||||
// baseUrl/apiKey/model/fallback/multimodal/maxTokens).
|
||||
type llmProviderRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Model string `json:"model"`
|
||||
Multimodal bool `json:"multimodal"`
|
||||
Fallback string `json:"fallback"`
|
||||
// MaxTokens is the group's own token budget. Zero means unset (the runtime
|
||||
// falls back to LLM_FALLBACK_MAX_TOKENS on the last fallback step, then the
|
||||
// adapter default). Negative is rejected.
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
ID *string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
BaseURL *string `json:"baseUrl"`
|
||||
APIKey *string `json:"apiKey"`
|
||||
Model *string `json:"model"`
|
||||
Multimodal *bool `json:"multimodal"`
|
||||
Fallback *string `json:"fallback"`
|
||||
// MaxTokens is the group's own token budget. Absent preserves the stored
|
||||
// budget (0 = unset for a new group). Negative is rejected.
|
||||
MaxTokens *int `json:"maxTokens"`
|
||||
}
|
||||
|
||||
// adminAddLlmProvider saves a provider group to models.json (read-modify-write,
|
||||
// promoting it to the active group) and hot-reloads the running LLM stack so the
|
||||
// new provider/model takes effect without a restart (design/04 §3.1). The API key
|
||||
// is persisted to the gitignored file but never returned or logged.
|
||||
// new provider/model takes effect without a restart (design/04 §3.1). A field the
|
||||
// client omits keeps its stored value, so a partial edit never resets a budget or
|
||||
// key. The API key is persisted to the gitignored file but never returned/logged.
|
||||
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
var req llmProviderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "baseUrl and model are required"))
|
||||
return
|
||||
}
|
||||
if req.MaxTokens < 0 {
|
||||
if req.MaxTokens != nil && *req.MaxTokens < 0 {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "maxTokens must be >= 0"))
|
||||
return
|
||||
}
|
||||
provider := req.Provider
|
||||
if provider == "" {
|
||||
provider = model.ProviderOpenAI
|
||||
}
|
||||
if provider != model.ProviderOpenAI && provider != model.ProviderAnthropic {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider"))
|
||||
return
|
||||
}
|
||||
if s.llm == nil {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入"))
|
||||
return
|
||||
}
|
||||
id := req.ID
|
||||
if id == "" {
|
||||
id = runtimeconfig.NewID()
|
||||
group, err := mergeLlmProvider(s.llm.Groups(), req)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err))
|
||||
return
|
||||
}
|
||||
// Promote the saved group to the active (index 0) slot, then persist and
|
||||
// hot-reload so Current() reflects the new provider/model immediately.
|
||||
groups := runtimeconfig.Activate(s.llm.Groups(), runtimeconfig.Group{
|
||||
ID: id,
|
||||
Provider: provider,
|
||||
BaseURL: req.BaseURL,
|
||||
APIKey: req.APIKey,
|
||||
Model: req.Model,
|
||||
Fallback: req.Fallback,
|
||||
Multimodal: req.Multimodal,
|
||||
MaxTokens: req.MaxTokens,
|
||||
})
|
||||
groups := runtimeconfig.Activate(s.llm.Groups(), group)
|
||||
if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
s.log.Error("llm save models", "path", s.llm.Path(), "error", err)
|
||||
@@ -117,15 +99,15 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
saved := AdminLlmProvider{
|
||||
ID: id,
|
||||
Name: providerDisplayName(runtimeconfig.Group{Provider: provider}),
|
||||
Provider: provider,
|
||||
BaseURL: req.BaseURL,
|
||||
Model: req.Model,
|
||||
Multimodal: req.Multimodal,
|
||||
Enabled: id == s.llm.Group().ID,
|
||||
Fallback: req.Fallback,
|
||||
MaxTokens: req.MaxTokens,
|
||||
ID: group.ID,
|
||||
Name: providerDisplayName(group),
|
||||
Provider: group.Provider,
|
||||
BaseURL: group.BaseURL,
|
||||
Model: group.Model,
|
||||
Multimodal: group.Multimodal,
|
||||
Enabled: group.ID == s.llm.Group().ID,
|
||||
Fallback: group.Fallback,
|
||||
MaxTokens: group.MaxTokens,
|
||||
}
|
||||
s.auditClaims(r, "llm.addProvider", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
@@ -135,6 +117,55 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// mergeLlmProvider applies a partial provider patch over the stored groups: a
|
||||
// field present in the request wins, an absent field keeps the stored value (or
|
||||
// its default for a new group). It returns a validated group.
|
||||
func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (runtimeconfig.Group, error) {
|
||||
id := ""
|
||||
if req.ID != nil {
|
||||
id = strings.TrimSpace(*req.ID)
|
||||
}
|
||||
g, found := runtimeconfig.Find(groups, id)
|
||||
if !found {
|
||||
g = runtimeconfig.Group{}
|
||||
}
|
||||
g.ID = id
|
||||
if g.ID == "" {
|
||||
g.ID = runtimeconfig.NewID()
|
||||
}
|
||||
if req.Provider != nil {
|
||||
g.Provider = strings.TrimSpace(*req.Provider)
|
||||
}
|
||||
if g.Provider == "" {
|
||||
g.Provider = model.ProviderOpenAI
|
||||
}
|
||||
if g.Provider != model.ProviderOpenAI && g.Provider != model.ProviderAnthropic {
|
||||
return runtimeconfig.Group{}, fmt.Errorf("provider must be openai or anthropic")
|
||||
}
|
||||
if req.BaseURL != nil {
|
||||
g.BaseURL = strings.TrimSpace(*req.BaseURL)
|
||||
}
|
||||
if req.APIKey != nil {
|
||||
g.APIKey = *req.APIKey
|
||||
}
|
||||
if req.Model != nil {
|
||||
g.Model = strings.TrimSpace(*req.Model)
|
||||
}
|
||||
if req.Fallback != nil {
|
||||
g.Fallback = strings.TrimSpace(*req.Fallback)
|
||||
}
|
||||
if req.Multimodal != nil {
|
||||
g.Multimodal = *req.Multimodal
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
g.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if g.BaseURL == "" || g.Model == "" {
|
||||
return runtimeconfig.Group{}, fmt.Errorf("baseUrl and model are required")
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// llmTestRequest is the POST /api/v1/admin/llm/test body.
|
||||
type llmTestRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
|
||||
@@ -301,6 +301,50 @@ func TestAdminAddLlmProviderSwitchProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAddLlmProviderPartialEditPreservesFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
||||
env := config.LLMConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o"}
|
||||
runtime := newLlmRuntimeForGateway(t, modelsPath, env)
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// Seed a fully-specified group.
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
||||
map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-chat", "multimodal": true, "fallback": "deepseek-lite", "maxTokens": 1234}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("seed status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Partial edit: only the model is given. Every omitted field must keep its
|
||||
// stored value — in particular maxTokens (a full replace reset it to 0) and
|
||||
// apiKey (a full replace wiped it).
|
||||
resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
||||
map[string]any{"id": "deepseek", "model": "deepseek-chat-v2"}, tok)
|
||||
if resp2.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("edit status = %d, want 201 (body=%s)", resp2.StatusCode, body2)
|
||||
}
|
||||
var res struct {
|
||||
Provider AdminLlmProvider `json:"provider"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body2)["data"], &res); err != nil {
|
||||
t.Fatalf("decode edit: %v", err)
|
||||
}
|
||||
p := res.Provider
|
||||
if p.Model != "deepseek-chat-v2" || p.MaxTokens != 1234 || p.Fallback != "deepseek-lite" ||
|
||||
!p.Multimodal || p.Provider != "openai" || p.BaseURL != "https://api.deepseek.com/v1" {
|
||||
t.Fatalf("edit provider = %+v, want model updated and every omitted field preserved", p)
|
||||
}
|
||||
|
||||
groups, warn := runtimeconfig.LoadModels(modelsPath, env)
|
||||
if warn != nil {
|
||||
t.Fatalf("load models warn = %v", warn)
|
||||
}
|
||||
if len(groups) == 0 || groups[0].APIKey != "sk-ds" || groups[0].MaxTokens != 1234 || groups[0].Model != "deepseek-chat-v2" {
|
||||
t.Fatalf("file groups = %+v, want apiKey/maxTokens preserved and model updated", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
||||
|
||||
Reference in new issue
Block a user