Files
sundynix-agentix/sundynix-admin/src/ModelsPage.tsx
T
Blizzard 6f5b98f186 feat: 独立运维控制台 (sundynix-admin) — LLM 模型配置控制面前端
新增独立 Vite+React 控制台(:5174),运维在此配置控制面,区别于桌面端构建者 UI。

- sundynix-admin: 模型页(列表[激活徽标/脱敏key] + 登记表单 + 测试连接 + 激活/删除)
  → 调 Gateway /api/v1/admin/models*;数据源/租户/护栏 占位(规划中);Gateway 健康灯
- gateway CORS 补 DELETE(控制台删除模型用)
- Makefile admin 目标(Vite :5174); .claude/launch.json 加 admin-console

验证: npm build✓; 真实浏览器跑通——控制台拉到真实模型列表, 填表→测试连接
✓连接成功(HTTP 200,browser→GW→mock), 保存→表格新增行。激活会经 NATS 热更新
Dispatcher(上一提交已证)。控制台↔控制面↔Dispatcher 全链路打通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:54:01 +08:00

180 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import {
listModels,
saveModel,
setActive,
deleteModel,
testModel,
type Model,
type ModelInput,
} from "./api";
const EMPTY: ModelInput = {
provider: "openai-compatible",
base_url: "",
api_key: "",
model: "",
};
// 模型配置页:登记/激活/删除 + 测试连接。激活后经 NATS 热更新到 Dispatcher。
export function ModelsPage() {
const [models, setModels] = useState<Model[]>([]);
const [form, setForm] = useState<ModelInput>(EMPTY);
const [msg, setMsg] = useState("");
const [testing, setTesting] = useState(false);
const refresh = () => listModels().then(setModels).catch((e) => setMsg(`${e.message}`));
useEffect(() => {
refresh();
}, []);
const set = (k: keyof ModelInput, v: string) => setForm((f) => ({ ...f, [k]: v }));
const onSave = async () => {
try {
await saveModel(form);
setMsg("✓ 已保存");
setForm(EMPTY);
refresh();
} catch (e) {
setMsg(`${(e as Error).message}`);
}
};
const onTest = async () => {
setTesting(true);
try {
const r = await testModel(form);
setMsg(r.ok ? `✓ 连接成功(${r.message}` : `✗ 连接失败:${r.message}`);
} catch (e) {
setMsg(`${(e as Error).message}`);
} finally {
setTesting(false);
}
};
return (
<div className="flex flex-col gap-6">
<section>
<h2 className="mb-3 text-sm font-semibold text-gray-700"></h2>
<div className="overflow-hidden rounded border">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs text-gray-500">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">Provider</th>
<th className="px-3 py-2">Base URL</th>
<th className="px-3 py-2">Model</th>
<th className="px-3 py-2">API Key</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{models.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-xs text-gray-400">
使
</td>
</tr>
)}
{models.map((m) => (
<tr key={m.id} className="border-t">
<td className="px-3 py-2">
{m.active ? (
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] text-emerald-700">
</span>
) : (
<span className="text-[10px] text-gray-400"></span>
)}
</td>
<td className="px-3 py-2 text-gray-600">{m.provider}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-600">{m.base_url}</td>
<td className="px-3 py-2 text-gray-800">{m.model}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-400">{m.api_key || "—"}</td>
<td className="px-3 py-2">
<div className="flex gap-2">
{!m.active && (
<button
onClick={() => setActive(m.id).then(() => { setMsg("✓ 已激活并热更新 Dispatcher"); refresh(); })}
className="rounded border px-2 py-0.5 text-xs text-violet-600 hover:bg-violet-50"
>
</button>
)}
<button
onClick={() => deleteModel(m.id).then(refresh)}
className="rounded border px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-50"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="max-w-xl">
<h2 className="mb-3 text-sm font-semibold text-gray-700">线 APIOpenAI </h2>
<div className="grid grid-cols-2 gap-3">
<label className="text-xs text-gray-500">
Provider
<select
className="mt-1 w-full rounded border px-2 py-1 text-sm text-gray-900"
value={form.provider}
onChange={(e) => set("provider", e.target.value)}
>
<option value="openai-compatible">openai-compatible</option>
<option value="vllm">vllm</option>
</select>
</label>
<label className="text-xs text-gray-500">
Model
<input
className="mt-1 w-full rounded border px-2 py-1 text-sm"
value={form.model}
onChange={(e) => set("model", e.target.value)}
placeholder="deepseek-chat"
/>
</label>
<label className="col-span-2 text-xs text-gray-500">
Base URL
<input
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
value={form.base_url}
onChange={(e) => set("base_url", e.target.value)}
placeholder="https://api.deepseek.com/v1"
/>
</label>
<label className="col-span-2 text-xs text-gray-500">
API Key
<input
type="password"
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
value={form.api_key}
onChange={(e) => set("api_key", e.target.value)}
placeholder="sk-…"
/>
</label>
</div>
<div className="mt-3 flex items-center gap-2">
<button onClick={onSave} className="rounded bg-violet-600 px-3 py-1 text-sm text-white">
</button>
<button
onClick={onTest}
disabled={testing || !form.base_url}
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
>
{testing ? "测试中…" : "测试连接"}
</button>
{msg && <span className="text-xs text-gray-600">{msg}</span>}
</div>
</section>
</div>
);
}