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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user