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)。
|
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
|
||||||
|
|
||||||
## 5. 变更记录
|
## 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.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.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` 去重兜底,与现有绕行一致)。
|
- `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 { useState } from "react";
|
||||||
import { useAsyncData } from "../../lib/useAsyncData";
|
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 type { AdminLlmProvider, AdminLlmConfig } from "../../lib/types";
|
||||||
import { useAuditStore } from "../audit";
|
import { useAuditStore } from "../audit";
|
||||||
import { Notice, PageHeader } from "../components";
|
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 = {
|
const sample: AdminLlmConfig = {
|
||||||
providers: [
|
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 },
|
{ 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 {
|
export function LlmModule(): React.ReactNode {
|
||||||
const { data, error, reload } = useAsyncData(adminGetLlm, []);
|
const { data, error, reload } = useAsyncData(adminGetLlm, []);
|
||||||
const log = useAuditStore((s) => s.log);
|
const log = useAuditStore((s) => s.log);
|
||||||
const backend = !isNotImplemented(error);
|
const backend = !isNotImplemented(error);
|
||||||
const providers = backend && data ? data.providers : sample.providers;
|
const providers = backend && data ? data.providers : sample.providers;
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [editing, setEditing] = useState<AdminLlmProvider | null>(null);
|
||||||
const [provider, setProvider] = useState("openai");
|
|
||||||
const [model, setModel] = useState("");
|
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
const [multimodal, setMultimodal] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [fail, setFail] = useState<string | null>(null);
|
const [fail, setFail] = useState<string | null>(null);
|
||||||
const [result, setResult] = 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) => {
|
const test = (p: AdminLlmProvider) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setFail(null);
|
setFail(null);
|
||||||
@@ -72,12 +80,7 @@ export function LlmModule(): React.ReactNode {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="LLM 模型"
|
title="LLM 模型"
|
||||||
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试(真实调用) + fallback + 按角色路由 + 用量"
|
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
|
||||||
actions={
|
|
||||||
<Button variant="primary" disabled={busy || !model || !baseUrl} onClick={addProvider}>
|
|
||||||
保存模型
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{backend && !error ? <Notice tone="ok">后端已返回 LLM 配置(数据以接口为准)。</Notice> : null}
|
{backend && !error ? <Notice tone="ok">后端已返回 LLM 配置(数据以接口为准)。</Notice> : null}
|
||||||
@@ -89,57 +92,20 @@ export function LlmModule(): React.ReactNode {
|
|||||||
<Surface className="space-y-3">
|
<Surface className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
|
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
|
||||||
{providers.map((r) => (
|
{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">
|
<ProviderRow key={r.id} p={r} busy={busy} onTest={test} onEdit={setEditing} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
<Surface className="space-y-3">
|
<LlmProviderForm
|
||||||
<h3 className="text-sm font-semibold">新增模型(保存到 models.json,即刻热更新)</h3>
|
key={editing?.id ?? "new"}
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
initial={editing}
|
||||||
<label className="block space-y-1">
|
busy={busy}
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">名称 / ID(留空自动生成)</span>
|
setBusy={setBusy}
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="deepseek" className={inputCls} />
|
onResult={setResult}
|
||||||
</label>
|
onError={setFail}
|
||||||
<label className="block space-y-1">
|
onSaved={reload}
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">协议 Provider</span>
|
onCancel={() => setEditing(null)}
|
||||||
<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>
|
|
||||||
|
|
||||||
<Surface className="space-y-3">
|
<Surface className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
|
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
|
||||||
@@ -152,10 +118,7 @@ export function LlmModule(): React.ReactNode {
|
|||||||
))}
|
))}
|
||||||
</Surface>
|
</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>
|
</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 { useState } from "react";
|
||||||
import { useAsyncData } from "../../lib/useAsyncData";
|
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 type { AdminPluginRecord } from "../../lib/types";
|
||||||
import { useAutoReload } from "../hooks";
|
import { useAutoReload } from "../hooks";
|
||||||
import { useAuditStore } from "../audit";
|
import { useAuditStore } from "../audit";
|
||||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||||
import { Button, Surface, Switch, Tag } from "../../components/primitives";
|
import { Button, Surface, Switch, Tag } from "../../components/primitives";
|
||||||
|
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
|
||||||
|
|
||||||
export function PluginsModule(): React.ReactNode {
|
/** Columns for the registered-capability table (GET /admin/plugins). */
|
||||||
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
|
function registeredColumns(
|
||||||
useAutoReload(reload, 10000);
|
isOwner: boolean,
|
||||||
const isOwner = getStoredRole() === "owner";
|
busy: string | null,
|
||||||
const log = useAuditStore((s) => s.log);
|
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
uninstall: (name: string) => void,
|
||||||
const [fail, setFail] = useState<string | null>(null);
|
): AdminColumn<AdminPluginRecord>[] {
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
return [
|
||||||
|
|
||||||
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>[] = [
|
|
||||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||||
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{p.kind}</Tag> },
|
{ 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> },
|
{ 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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="插件与能力"
|
title="插件与能力"
|
||||||
subtitle="GET /api/v1/admin/plugins · 启停/安装/卸载/MCP 连接 · POST /api/v1/admin/plugins/policy (owner-only)"
|
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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略仅 owner 可写(读取/查看可见)。</Notice> : null}
|
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
|
||||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||||
{msg ? <Notice tone="ok">{msg}</Notice> : null}
|
{msg ? <Notice tone="ok">{msg}</Notice> : null}
|
||||||
|
|
||||||
|
<InstalledPluginsPanel />
|
||||||
|
|
||||||
<Surface className="space-y-2">
|
<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
|
<AdminTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={loading ? null : data}
|
rows={loading ? null : data}
|
||||||
@@ -132,7 +127,7 @@ export function PluginsModule(): React.ReactNode {
|
|||||||
/>
|
/>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
<Notice>工具 schema / 技能 / MCP 服务器列表为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。</Notice>
|
<Notice>工具 schema / 技能为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。</Notice>
|
||||||
</div>
|
</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,
|
ChannelTestResult,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSyncResult,
|
ChatSyncResult,
|
||||||
|
InstalledPlugin,
|
||||||
LlmProviderRequest,
|
LlmProviderRequest,
|
||||||
LlmTestRequest,
|
LlmTestRequest,
|
||||||
LlmTestResult,
|
LlmTestResult,
|
||||||
@@ -32,6 +33,8 @@ import type {
|
|||||||
Mode,
|
Mode,
|
||||||
PersonaRecord,
|
PersonaRecord,
|
||||||
PersonaUpsertRequest,
|
PersonaUpsertRequest,
|
||||||
|
PluginInstallRequest,
|
||||||
|
PluginInstallResult,
|
||||||
KookFriendList,
|
KookFriendList,
|
||||||
KookMessageTestResult,
|
KookMessageTestResult,
|
||||||
KookViewResponse,
|
KookViewResponse,
|
||||||
@@ -391,6 +394,11 @@ export function adminListMemories(): Promise<MemoryRecord[]> {
|
|||||||
export function adminListPlugins(): Promise<AdminPluginRecord[]> {
|
export function adminListPlugins(): Promise<AdminPluginRecord[]> {
|
||||||
return request<AdminPluginRecord[]>("/admin/plugins");
|
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. */
|
/** POST /api/v1/admin/plugins/policy — owner-only, audited. */
|
||||||
export function adminSetPluginPolicy(
|
export function adminSetPluginPolicy(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -677,15 +685,18 @@ export function adminWeixinLoginStatus(
|
|||||||
`/admin/channels/${encodeURIComponent(platform)}/login-status${q}`,
|
`/admin/channels/${encodeURIComponent(platform)}/login-status${q}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
|
/** POST /api/v1/admin/plugins/install — installs (or refreshes) an MCP server
|
||||||
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
|
* plugin (v0.28): connects, discovers its tools and hot-registers them. The
|
||||||
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
|
* 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. */
|
/** POST /api/v1/admin/plugins/uninstall — removes an installed MCP server (tools
|
||||||
export function adminPluginUninstall(req: { name: string }): Promise<{ name: string; uninstalled: boolean }> {
|
* unregistered + config deleted) or a plain capability from the registry. */
|
||||||
return request<{ name: string; uninstalled: boolean }>("/admin/plugins/uninstall", {
|
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",
|
method: "POST",
|
||||||
body: JSON.stringify(req),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/** POST /api/v1/admin/memories/search — real retrieval over the memory store. */
|
/** POST /api/v1/admin/memories/search — real retrieval over the memory store. */
|
||||||
|
|||||||
@@ -261,6 +261,25 @@ export interface AdminPluginRecord {
|
|||||||
allowInStranger: boolean;
|
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. */
|
/** A channel adapter as returned by GET /api/v1/admin/channels. */
|
||||||
export interface AdminChannelRecord {
|
export interface AdminChannelRecord {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -355,6 +374,9 @@ export interface AdminLlmProvider {
|
|||||||
multimodal: boolean;
|
multimodal: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
fallback?: string;
|
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 {
|
export interface AdminLlmConfig {
|
||||||
providers: AdminLlmProvider[];
|
providers: AdminLlmProvider[];
|
||||||
@@ -508,7 +530,9 @@ export interface AutomationRunResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/v1/admin/llm body — saves a provider group to models.json and
|
/** 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 {
|
export interface LlmProviderRequest {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -518,6 +542,8 @@ export interface LlmProviderRequest {
|
|||||||
model: string;
|
model: string;
|
||||||
multimodal?: boolean;
|
multimodal?: boolean;
|
||||||
fallback?: string;
|
fallback?: string;
|
||||||
|
/** Absent = keep the stored budget (or leave unset for a new group). */
|
||||||
|
maxTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/v1/admin/llm/test body. */
|
/** POST /api/v1/admin/llm/test body. */
|
||||||
@@ -538,6 +564,34 @@ export interface LlmTestResult {
|
|||||||
tokens: number;
|
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). */
|
/** POST /api/v1/admin/channels/test result (structural identity verdict). */
|
||||||
export interface ChannelTestResult {
|
export interface ChannelTestResult {
|
||||||
role: string;
|
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,
|
// 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
|
// preserving order. A blank ID is assigned a generated id so a panel save
|
||||||
// without an explicit id still produces a stable, addressable group.
|
// without an explicit id still produces a stable, addressable group.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -36,74 +37,55 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a
|
// 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
|
// provider group. Every field is a pointer so the handler can tell "absent"
|
||||||
// models.json schema carries id/provider/baseUrl/apiKey/model/fallback/
|
// (preserve the stored value) from "explicitly zero" (overwrite): a partial edit
|
||||||
// multimodal/maxTokens).
|
// 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 {
|
type llmProviderRequest struct {
|
||||||
ID string `json:"id"`
|
ID *string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Provider string `json:"provider"`
|
Provider *string `json:"provider"`
|
||||||
BaseURL string `json:"baseUrl"`
|
BaseURL *string `json:"baseUrl"`
|
||||||
APIKey string `json:"apiKey"`
|
APIKey *string `json:"apiKey"`
|
||||||
Model string `json:"model"`
|
Model *string `json:"model"`
|
||||||
Multimodal bool `json:"multimodal"`
|
Multimodal *bool `json:"multimodal"`
|
||||||
Fallback string `json:"fallback"`
|
Fallback *string `json:"fallback"`
|
||||||
// MaxTokens is the group's own token budget. Zero means unset (the runtime
|
// MaxTokens is the group's own token budget. Absent preserves the stored
|
||||||
// falls back to LLM_FALLBACK_MAX_TOKENS on the last fallback step, then the
|
// budget (0 = unset for a new group). Negative is rejected.
|
||||||
// adapter default). Negative is rejected.
|
MaxTokens *int `json:"maxTokens"`
|
||||||
MaxTokens int `json:"maxTokens"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminAddLlmProvider saves a provider group to models.json (read-modify-write,
|
// 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
|
// 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
|
// new provider/model takes effect without a restart (design/04 §3.1). A field the
|
||||||
// is persisted to the gitignored file but never returned or logged.
|
// 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) {
|
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
var req llmProviderRequest
|
var req llmProviderRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
|
if req.MaxTokens != nil && *req.MaxTokens < 0 {
|
||||||
s.auditClaims(r, "llm.addProvider", "error")
|
|
||||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "baseUrl and model are required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.MaxTokens < 0 {
|
|
||||||
s.auditClaims(r, "llm.addProvider", "error")
|
s.auditClaims(r, "llm.addProvider", "error")
|
||||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "maxTokens must be >= 0"))
|
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "maxTokens must be >= 0"))
|
||||||
return
|
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 {
|
if s.llm == nil {
|
||||||
s.auditClaims(r, "llm.addProvider", "error")
|
s.auditClaims(r, "llm.addProvider", "error")
|
||||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入"))
|
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := req.ID
|
group, err := mergeLlmProvider(s.llm.Groups(), req)
|
||||||
if id == "" {
|
if err != nil {
|
||||||
id = runtimeconfig.NewID()
|
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
|
// Promote the saved group to the active (index 0) slot, then persist and
|
||||||
// hot-reload so Current() reflects the new provider/model immediately.
|
// hot-reload so Current() reflects the new provider/model immediately.
|
||||||
groups := runtimeconfig.Activate(s.llm.Groups(), runtimeconfig.Group{
|
groups := runtimeconfig.Activate(s.llm.Groups(), group)
|
||||||
ID: id,
|
|
||||||
Provider: provider,
|
|
||||||
BaseURL: req.BaseURL,
|
|
||||||
APIKey: req.APIKey,
|
|
||||||
Model: req.Model,
|
|
||||||
Fallback: req.Fallback,
|
|
||||||
Multimodal: req.Multimodal,
|
|
||||||
MaxTokens: req.MaxTokens,
|
|
||||||
})
|
|
||||||
if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil {
|
if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil {
|
||||||
s.auditClaims(r, "llm.addProvider", "error")
|
s.auditClaims(r, "llm.addProvider", "error")
|
||||||
s.log.Error("llm save models", "path", s.llm.Path(), "error", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
saved := AdminLlmProvider{
|
saved := AdminLlmProvider{
|
||||||
ID: id,
|
ID: group.ID,
|
||||||
Name: providerDisplayName(runtimeconfig.Group{Provider: provider}),
|
Name: providerDisplayName(group),
|
||||||
Provider: provider,
|
Provider: group.Provider,
|
||||||
BaseURL: req.BaseURL,
|
BaseURL: group.BaseURL,
|
||||||
Model: req.Model,
|
Model: group.Model,
|
||||||
Multimodal: req.Multimodal,
|
Multimodal: group.Multimodal,
|
||||||
Enabled: id == s.llm.Group().ID,
|
Enabled: group.ID == s.llm.Group().ID,
|
||||||
Fallback: req.Fallback,
|
Fallback: group.Fallback,
|
||||||
MaxTokens: req.MaxTokens,
|
MaxTokens: group.MaxTokens,
|
||||||
}
|
}
|
||||||
s.auditClaims(r, "llm.addProvider", "ok")
|
s.auditClaims(r, "llm.addProvider", "ok")
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{
|
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.
|
// llmTestRequest is the POST /api/v1/admin/llm/test body.
|
||||||
type llmTestRequest struct {
|
type llmTestRequest struct {
|
||||||
Provider string `json:"provider"`
|
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) {
|
func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
||||||
|
|||||||
Reference in new issue
Block a user