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

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

No files matched your search

+52 -89
View File
@@ -1,56 +1,64 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, adminAddLlmProvider, adminTestLlm, isNotImplemented } from "../../lib/api";
import { adminGetLlm, adminTestLlm, isNotImplemented } from "../../lib/api";
import type { AdminLlmProvider, AdminLlmConfig } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { Button, Surface, Tag } from "../../components/primitives";
import { LlmProviderForm } from "./llm/LlmProviderForm";
const sample: AdminLlmConfig = {
providers: [
{ id: "p1", name: "主模型", provider: "openai", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true },
{ id: "p1", name: "主模型", provider: "openai", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true, maxTokens: 8192 },
{ id: "p2", name: "备选模型", provider: "openai", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true },
],
};
/** One provider group row: identity + current token budget + test/edit actions. */
function ProviderRow({
p,
busy,
onTest,
onEdit,
}: {
p: AdminLlmProvider;
busy: boolean;
onTest: (p: AdminLlmProvider) => void;
onEdit: (p: AdminLlmProvider) => void;
}): React.ReactNode {
return (
<div className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<span className="font-medium">{p.name}</span>
<Tag tone="neutral">{p.model}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.baseUrl || "—"}</span>
<Tag tone={p.maxTokens ? "accent" : "neutral"}>
{p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"}
</Tag>
<span className="ml-auto flex items-center gap-4">
<Tag tone={p.enabled ? "ok" : "neutral"}>{p.enabled ? "启用" : "停用"}</Tag>
<Tag tone={p.multimodal ? "accent" : "neutral"}>{p.multimodal ? "多模态" : "文本"}</Tag>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onEdit(p)}>
编辑
</Button>
<Button size="sm" variant="ghost" disabled={busy || !p.baseUrl} onClick={() => onTest(p)}>
测试
</Button>
</span>
</div>
);
}
export function LlmModule(): React.ReactNode {
const { data, error, reload } = useAsyncData(adminGetLlm, []);
const log = useAuditStore((s) => s.log);
const backend = !isNotImplemented(error);
const providers = backend && data ? data.providers : sample.providers;
const [name, setName] = useState("");
const [provider, setProvider] = useState("openai");
const [model, setModel] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [multimodal, setMultimodal] = useState(false);
const [editing, setEditing] = useState<AdminLlmProvider | null>(null);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const addProvider = () => {
setBusy(true);
setFail(null);
setResult(null);
adminAddLlmProvider({ id: name || undefined, provider, baseUrl, apiKey, model, multimodal })
.then((r) => {
log("llm.addProvider", "ok");
setResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${r.path ? `(${r.path})` : ""}`);
setName("");
setModel("");
setBaseUrl("");
setApiKey("");
setMultimodal(false);
reload();
})
.catch((e: unknown) => {
log("llm.addProvider", "error");
setFail(e instanceof Error ? e.message : "新增模型失败");
})
.finally(() => setBusy(false));
};
const test = (p: AdminLlmProvider) => {
setBusy(true);
setFail(null);
@@ -72,12 +80,7 @@ export function LlmModule(): React.ReactNode {
<div className="space-y-5">
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试(真实调用) + fallback + 按角色路由 + 用量"
actions={
<Button variant="primary" disabled={busy || !model || !baseUrl} onClick={addProvider}>
保存模型
</Button>
}
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
/>
{backend && !error ? <Notice tone="ok">后端已返回 LLM 配置(数据以接口为准)。</Notice> : null}
@@ -89,57 +92,20 @@ export function LlmModule(): React.ReactNode {
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
{providers.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.model}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.baseUrl || "—"}</span>
<span className="ml-auto flex items-center gap-4">
<Tag tone={r.enabled ? "ok" : "neutral"}>{r.enabled ? "启用" : "停用"}</Tag>
<Tag tone={r.multimodal ? "accent" : "neutral"}>{r.multimodal ? "多模态" : "文本"}</Tag>
<Button size="sm" variant="ghost" disabled={busy || !r.baseUrl} onClick={() => test(r)}>
测试
</Button>
</span>
</div>
<ProviderRow key={r.id} p={r} busy={busy} onTest={test} onEdit={setEditing} />
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">新增模型(保存到 models.json,即刻热更新)</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称 / ID(留空自动生成)</span>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="deepseek" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">协议 Provider</span>
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={inputCls}>
<option value="openai">openai(OpenAI 兼容)</option>
<option value="anthropic">anthropic(Anthropic Messages)</option>
</select>
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">模型</span>
<input value={model} onChange={(e) => setModel(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL</span>
<input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">API key(写入 models.json,不回显)</span>
<input value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-…" className={inputCls} />
</label>
<div className="flex items-end gap-2 pb-[2px]">
<Switch checked={multimodal} onChange={setMultimodal} label="多模态" />
</div>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">保存后写入 models.json(本地、已 gitignore),并热更新运行中的 LLM provider,无需重启进程。API key 不在列表/响应中回显。</p>
</Surface>
<LlmProviderForm
key={editing?.id ?? "new"}
initial={editing}
busy={busy}
setBusy={setBusy}
onResult={setResult}
onError={setFail}
onSaved={reload}
onCancel={() => setEditing(null)}
/>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
@@ -152,10 +118,7 @@ export function LlmModule(): React.ReactNode {
))}
</Surface>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。</Notice>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。</Notice>
</div>
);
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";