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

@@ -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";