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