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>
This commit is contained in:
Blizzard
2026-06-10 15:54:01 +08:00
parent 3c65189f30
commit 6f5b98f186
16 changed files with 3085 additions and 3 deletions
+7
View File
@@ -7,6 +7,13 @@
"runtimeArgs": ["run", "dev", "--", "--port", "5173"],
"cwd": "sundynix-desktop/frontend",
"port": 5173
},
{
"name": "admin-console",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5174"],
"cwd": "sundynix-admin",
"port": 5174
}
]
}
+5 -2
View File
@@ -1,4 +1,4 @@
.PHONY: infra infra-down devnats demo e2e gateway dispatcher mcp-go mcp-py mcp-py-setup web desktop tidy
.PHONY: infra infra-down devnats demo e2e gateway dispatcher mcp-go mcp-py mcp-py-setup web admin desktop tidy
infra: ## 启动基础设施 (NATS / Postgres / Redis / Milvus / Neo4j)
docker compose up -d
@@ -31,9 +31,12 @@ mcp-py: ## 运行 Python 算法型 MCP 工具服务(缺 venv 则自动 s
cd sundynix-mcp-py && [ -x .venv/bin/python ] || $(MAKE) mcp-py-setup
cd sundynix-mcp-py && .venv/bin/python -m sundynix_mcp_py.main
web: ## 前端 devVite,连本地 Gateway:8080;先 npm install
web: ## 桌面端前端 devVite :5173,连本地 Gateway:8080
cd sundynix-desktop/frontend && npm install && npm run dev
admin: ## 运维控制台 devVite :5174,配置 LLM 模型等控制面)
cd sundynix-admin && npm install && npm run dev
desktop:
cd sundynix-desktop && wails dev
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sundynix-agentix · 运维控制台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2654
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "sundynix-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { ModelsPage } from "./ModelsPage";
import { gatewayOnline } from "./api";
type Tab = "models" | "datasources" | "tenants" | "guardrails";
const NAV: Array<{ key: Tab; label: string; ready?: boolean }> = [
{ key: "models", label: "模型", ready: true },
{ key: "datasources", label: "数据源" },
{ key: "tenants", label: "租户" },
{ key: "guardrails", label: "护栏" },
];
function Soon({ title, desc }: { title: string; desc: string }) {
return (
<div className="rounded-lg border border-dashed bg-gray-50 p-6">
<div className="mb-1 text-sm font-semibold text-gray-600">{title}</div>
<p className="text-xs leading-relaxed text-gray-400">{desc}</p>
<span className="mt-3 inline-block rounded bg-gray-200 px-2 py-0.5 text-[10px] text-gray-500"></span>
</div>
);
}
export default function App() {
const [tab, setTab] = useState<Tab>("models");
const [online, setOnline] = useState(false);
useEffect(() => {
const ping = () => gatewayOnline().then(setOnline);
ping();
const id = setInterval(ping, 4000);
return () => clearInterval(id);
}, []);
return (
<div className="flex h-screen w-screen text-gray-900">
<aside className="flex w-56 shrink-0 flex-col border-r bg-gray-50">
<div className="border-b p-4">
<div className="text-sm font-bold text-gray-800">sundynix-agentix</div>
<div className="text-[11px] text-gray-400"></div>
</div>
<nav className="flex flex-col p-2">
{NAV.map((n) => (
<button
key={n.key}
onClick={() => setTab(n.key)}
className={`flex items-center justify-between rounded px-3 py-2 text-left text-sm ${
tab === n.key ? "bg-violet-50 font-medium text-violet-700" : "text-gray-600 hover:bg-gray-100"
}`}
>
{n.label}
{!n.ready && <span className="text-[9px] text-gray-300"></span>}
</button>
))}
</nav>
<div className="mt-auto flex items-center gap-2 border-t p-4 text-[11px] text-gray-500">
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
Gateway {online ? "在线" : "离线"}
</div>
</aside>
<main className="flex-1 overflow-auto p-6">
<h1 className="mb-4 text-lg font-semibold text-gray-800">
{NAV.find((n) => n.key === tab)?.label}
</h1>
{tab === "models" && <ModelsPage />}
{tab === "datasources" && (
<Soon
title="数据源(向量库 / 图库 / 全文)"
desc="配置 Milvus(:19530) / Neo4j(:7687) / Bleve 连接 + 测试连接 + 状态。后端 search.Hybrid 接真后接通(RAG 核心链)。"
/>
)}
{tab === "tenants" && (
<Soon title="租户 / 工作区" desc="多租户隔离、配额、用户与计费。垂直行业平台级复制的基座。" />
)}
{tab === "guardrails" && (
<Soon title="护栏" desc="输入/输出 Guardrail 规则(脱敏 / 免责 / 强制引用)。受监管垂直必备。" />
)}
</main>
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
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>
);
}
+66
View File
@@ -0,0 +1,66 @@
// 运维控制台 → Gateway 控制面 API。
export const GATEWAY: string =
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
const ADMIN = `${GATEWAY}/api/v1/admin`;
export interface Model {
id: number;
provider: string;
base_url: string;
api_key: string; // 列表里是脱敏值
model: string;
active: boolean;
}
export interface ModelInput {
id?: number;
provider: string;
base_url: string;
api_key: string;
model: string;
}
export async function listModels(): Promise<Model[]> {
const res = await fetch(`${ADMIN}/models`);
if (!res.ok) throw new Error(`list failed: ${res.status}`);
return ((await res.json()) as { models: Model[] }).models;
}
export async function saveModel(m: ModelInput): Promise<number> {
const res = await fetch(`${ADMIN}/models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(m),
});
const data = (await res.json()) as { id?: number; error?: string };
if (!res.ok) throw new Error(data.error ?? `save failed: ${res.status}`);
return data.id ?? 0;
}
export async function setActive(id: number): Promise<void> {
const res = await fetch(`${ADMIN}/models/${id}/active`, { method: "POST" });
if (!res.ok) throw new Error(`activate failed: ${res.status}`);
}
export async function deleteModel(id: number): Promise<void> {
const res = await fetch(`${ADMIN}/models/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`delete failed: ${res.status}`);
}
export async function testModel(m: ModelInput): Promise<{ ok: boolean; message: string }> {
const res = await fetch(`${ADMIN}/models/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(m),
});
return (await res.json()) as { ok: boolean; message: string };
}
export async function gatewayOnline(): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY}/api/v1/billing`);
return res.ok;
} catch {
return false;
}
}
+10
View File
@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
margin: 0;
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { extend: {} },
plugins: [],
};
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 5174 },
});
+1 -1
View File
@@ -42,7 +42,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
func cors() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, X-User-ID, X-Session-ID")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)