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