feat: upgrade Modell-Zentrale to Model Control HUD 3.0, delete obsolete RoutingView
This commit is contained in:
-1
File diff suppressed because one or more lines are too long
+330
File diff suppressed because one or more lines are too long
-345
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-DQSWM8o1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-1l8zG4Un.css">
|
||||
<script type="module" crossorigin src="/assets/index-Baq7uvMX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-vKzSSNQJ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+523
-120
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive } from "lucide-react"
|
||||
import { api, type DiscoverResp, type Fit, type Job, type ModelInfo } from "@/lib/api"
|
||||
import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive, X, Check, Copy } from "lucide-react"
|
||||
import { api, type DiscoverResp, type Fit, type Job, type ModelInfo, type RoutingResp, type UpdatesResp, type ConnectResp } from "@/lib/api"
|
||||
import { CapsChips } from "@/components/CapsChips"
|
||||
import { RoutingView } from "./RoutingView"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function fmtBytes(b?: number) {
|
||||
@@ -61,7 +60,7 @@ function JobsBar() {
|
||||
</span>
|
||||
<button
|
||||
onClick={() => cancelJob(j.id)}
|
||||
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all"
|
||||
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
@@ -111,19 +110,36 @@ function FitBadge({ fit }: { fit: Fit }) {
|
||||
)
|
||||
}
|
||||
|
||||
const ROLES = ["", "fast", "heavy", "coder", "reasoning", "agent", "vision", "scout"]
|
||||
const ROLES = ["fast", "heavy", "coder", "reasoning", "agent", "vision", "scout"]
|
||||
|
||||
function Installed() {
|
||||
function Cockpit() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([])
|
||||
const [running, setRunning] = useState<string[]>([])
|
||||
const [error, setError] = useState("")
|
||||
const [routing, setRouting] = useState<RoutingResp | null>(null)
|
||||
const [connectData, setConnectData] = useState<ConnectResp | null>(null)
|
||||
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
// UI state
|
||||
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | null>(null)
|
||||
const [activeRoleDrop, setActiveRoleDrop] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
||||
|
||||
function load() {
|
||||
api<{ models: ModelInfo[]; running?: string[] }>("/api/models")
|
||||
.then((d) => {
|
||||
setModels(d.models || [])
|
||||
setRunning(d.running || [])
|
||||
Promise.all([
|
||||
api<{ models: ModelInfo[]; running?: string[] }>("/api/models"),
|
||||
api<RoutingResp>("/api/routing"),
|
||||
api<ConnectResp>("/api/connect"),
|
||||
api<UpdatesResp>("/api/maintenance/updates")
|
||||
])
|
||||
.then(([mResp, rResp, cResp, uResp]) => {
|
||||
setModels(mResp.models || [])
|
||||
setRunning(mResp.running || [])
|
||||
setRouting(rResp)
|
||||
setConnectData(cResp)
|
||||
setUpdates(uResp)
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -131,130 +147,518 @@ function Installed() {
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(load, 3000)
|
||||
const t = setInterval(load, 4000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
async function setRole(name: string, role: string) {
|
||||
await api(`/api/models/${encodeURIComponent(name)}/role`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ role: role || null }),
|
||||
})
|
||||
load()
|
||||
async function handleRoleChange(role: string, modelName: string) {
|
||||
setActiveRoleDrop(null)
|
||||
try {
|
||||
await api(`/api/models/${encodeURIComponent(modelName)}/role`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ role: role || null }),
|
||||
})
|
||||
load()
|
||||
} catch (e) {
|
||||
alert(`Fehler beim Zuweisen der Rolle: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function setCtx(name: string, cur: number | null) {
|
||||
async function handleSetCtx(name: string, cur: number | null) {
|
||||
const v = prompt("Kontextlänge (Tokens):", String(cur || 32768))
|
||||
if (!v) return
|
||||
await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ctx: parseInt(v, 10) }),
|
||||
})
|
||||
load()
|
||||
try {
|
||||
await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ctx: parseInt(v, 10) }),
|
||||
})
|
||||
load()
|
||||
} catch (e) {
|
||||
alert(`Fehler beim Setzen des Kontexts: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function del(name: string) {
|
||||
async function handleDelete(name: string) {
|
||||
if (!confirm(`Modell '${name}' aus der Config entfernen? (GGUF-Datei bleibt)`)) return
|
||||
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
|
||||
load()
|
||||
try {
|
||||
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
|
||||
load()
|
||||
} catch (e) {
|
||||
alert(`Fehler beim Löschen: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-xs text-muted-foreground py-6 text-center">Lade installierte Modelle…</div>
|
||||
if (error)
|
||||
async function handleSmartUpgrade(repo: string, role: string, quant: string, toolCapable: boolean) {
|
||||
try {
|
||||
await api("/api/models/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
|
||||
})
|
||||
alert(`Download für '${repo}' gestartet! Der Fortschritt wird oben angezeigt.`)
|
||||
} catch (e) {
|
||||
alert(`Fehler beim Starten des Upgrades: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function copySnippet(snippet?: string) {
|
||||
if (!snippet) return
|
||||
await navigator.clipboard.writeText(snippet)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-xs text-muted-foreground py-12 text-center">Initialisiere HUD Cockpit…</div>
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
|
||||
Engine nicht erreichbar oder keine Config gefunden ({error}).
|
||||
Gateway oder Engine nicht erreichbar ({error}).
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// VRAM calculation: APU fallback 16GB if sysfs returns 0
|
||||
const runningWithInfo = models.filter((m) => running.includes(m.name))
|
||||
const totalRunningSize = runningWithInfo.reduce((acc, m) => acc + (m.size_bytes || 0), 0)
|
||||
const virtualMax = 16 * 1024 ** 3 // 16 GB Default
|
||||
const capacity = totalRunningSize > virtualMax ? totalRunningSize * 1.2 : virtualMax
|
||||
|
||||
// Find model by role
|
||||
const getModelForRole = (role: string) => models.find((m) => m.role === role)
|
||||
const isRoleRunning = (role: string) => {
|
||||
const m = getModelForRole(role)
|
||||
return m ? running.includes(m.name) : false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.length === 0 ? (
|
||||
<div className="col-span-full text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
|
||||
Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen.
|
||||
<div className="space-y-8">
|
||||
{/* Styles inside Cockpit for dash flow animations */}
|
||||
<style>{`
|
||||
@keyframes flow-dash {
|
||||
to {
|
||||
stroke-dashoffset: -40;
|
||||
}
|
||||
}
|
||||
.animate-flow-cyan {
|
||||
stroke-dasharray: 8 24;
|
||||
animation: flow-dash 1.2s linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* ZONE A: Widescreen VRAM HUD */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4.5 w-4.5 text-primary" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground">VRAM / Memory Belegung</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
Llama Swap VRAM-Pool: {fmtSize(totalRunningSize)} / {fmtSize(capacity)} geladen
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
models.map((m) => {
|
||||
const isRunning = running.includes(m.name)
|
||||
return (
|
||||
<div
|
||||
key={m.name}
|
||||
className={cn(
|
||||
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-4 transition-all duration-300 hover:border-primary/40 group",
|
||||
isRunning ? "border-primary/45 shadow-primary/5" : "border-border/60"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xs font-bold tracking-tight text-foreground line-clamp-2 break-all" title={m.name}>
|
||||
{m.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-muted-foreground bg-background/40 px-1.5 py-0.5 rounded border border-border/30">
|
||||
{m.quant || "GGUF"}
|
||||
</span>
|
||||
{isRunning && (
|
||||
<span className="flex items-center gap-1 text-[9px] font-semibold text-emerald-400 uppercase tracking-wider">
|
||||
<Activity className="h-3 w-3 animate-pulse" /> Warm
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => del(m.name)}
|
||||
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100 shrink-0"
|
||||
title="Modell aus Config entfernen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/30 pt-3 flex flex-wrap gap-1">
|
||||
<CapsChips caps={m.capabilities} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px] font-mono text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20">
|
||||
<HardDrive className="h-3.5 w-3.5 text-primary/80" />
|
||||
<div>
|
||||
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Größe</div>
|
||||
<div className="text-foreground font-semibold">{fmtSize(m.size_bytes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setCtx(m.name, m.ctx)}
|
||||
className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20 hover:border-primary/40 text-left transition-colors"
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5 text-primary/80" />
|
||||
<div>
|
||||
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Kontext</div>
|
||||
<div className="text-foreground font-semibold">{fmtCtx(m.ctx)}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/30 pt-3">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rolle</span>
|
||||
<select
|
||||
value={m.role || ""}
|
||||
onChange={(e) => setRole(m.name, e.target.value)}
|
||||
className="h-8 rounded-lg border border-border/60 bg-background/60 px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground cursor-pointer font-semibold min-w-[120px]"
|
||||
title="Weise diesem Modell eine Systemrolle zu"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r} className="bg-popover text-foreground">{r || "Keine Rolle"}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* The memory bar */}
|
||||
<div className="h-7 w-full rounded-xl bg-background/50 border border-border/40 overflow-hidden flex p-0.5 relative group">
|
||||
{runningWithInfo.length === 0 ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-[10px] text-muted-foreground/60 italic font-mono select-none">
|
||||
VRAM Leer — Auto-Swap lädt Modelle bei Anfrage
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
) : (
|
||||
runningWithInfo.map((m, idx) => {
|
||||
const widthPct = ((m.size_bytes || 0) / capacity) * 100
|
||||
const bgClass = [
|
||||
"from-teal-500 to-emerald-500",
|
||||
"from-indigo-500 to-blue-500",
|
||||
"from-purple-500 to-pink-500",
|
||||
"from-cyan-500 to-sky-500"
|
||||
][idx % 4]
|
||||
return (
|
||||
<div
|
||||
key={m.name}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
className={cn(
|
||||
"h-full rounded bg-gradient-to-r flex items-center justify-between px-2.5 transition-all text-white/95 shrink-0 shadow-inner select-none",
|
||||
bgClass
|
||||
)}
|
||||
title={`${m.name} (${fmtSize(m.size_bytes)})`}
|
||||
>
|
||||
<span className="text-[9px] font-bold truncate max-w-[120px] font-space tracking-wide">
|
||||
{m.role ? `[${m.role}] ` : ""}{m.name.split("/").pop()?.replace(".gguf", "")}
|
||||
</span>
|
||||
<span className="text-[8px] font-mono opacity-80">{fmtSize(m.size_bytes)}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZONE B: SVG Node Router */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 shadow-lg shadow-black/10 space-y-4">
|
||||
<div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Interactive Gateway Graph</span>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">
|
||||
Klicke links auf Editoren, um Configs zu kopieren. Klicke rechts auf Rollen, um GGUF-Zuweisungen live zu ändern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* The Graph Canvas Area */}
|
||||
<div className="relative w-full h-[360px] border border-border/30 rounded-xl bg-black/25 overflow-hidden flex">
|
||||
{/* SVG Overlay behind HTML Nodes */}
|
||||
<svg className="absolute inset-0 pointer-events-none w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
{/* Gradients */}
|
||||
<defs>
|
||||
<linearGradient id="cyan-to-teal" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#06b6d4" stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor="#0d9488" stopOpacity="0.45" />
|
||||
</linearGradient>
|
||||
<linearGradient id="active-glow" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.8" />
|
||||
<stop offset="100%" stopColor="#10b981" stopOpacity="0.8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Bezier Curves: Clients to Gateway (10% -> 50%) */}
|
||||
{/* Roo Code (y=18) */}
|
||||
<path d="M 10 18 C 30 18, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{(hoveredNode === "roocode" || activeClient === "roocode") && (
|
||||
<path d="M 10 18 C 30 18, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* Cursor (y=50) */}
|
||||
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{(hoveredNode === "cursor" || activeClient === "cursor") && (
|
||||
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* OpenCode (y=82) */}
|
||||
<path d="M 10 82 C 30 82, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{(hoveredNode === "opencode" || activeClient === "opencode") && (
|
||||
<path d="M 10 82 C 30 82, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* Bezier Curves: Gateway to Roles (50% -> 90%) */}
|
||||
{/* fast (y=12) */}
|
||||
<path d="M 50 50 C 70 50, 70 12, 90 12" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{isRoleRunning("fast") && (
|
||||
<path d="M 50 50 C 70 50, 70 12, 90 12" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* heavy (y=31) */}
|
||||
<path d="M 50 50 C 70 50, 70 31, 90 31" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{isRoleRunning("heavy") && (
|
||||
<path d="M 50 50 C 70 50, 70 31, 90 31" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* coder (y=50) */}
|
||||
<path d="M 50 50 C 70 50, 70 50, 90 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{isRoleRunning("coder") && (
|
||||
<path d="M 50 50 C 70 50, 70 50, 90 50" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* vision (y=69) */}
|
||||
<path d="M 50 50 C 70 50, 70 69, 90 69" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{isRoleRunning("vision") && (
|
||||
<path d="M 50 50 C 70 50, 70 69, 90 69" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* scout (y=88) */}
|
||||
<path d="M 50 50 C 70 50, 70 88, 90 88" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{isRoleRunning("scout") && (
|
||||
<path d="M 50 50 C 70 50, 70 88, 90 88" stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* HTML Nodes */}
|
||||
{/* COLUMN 1: Client nodes */}
|
||||
<div
|
||||
className="absolute cursor-pointer select-none z-10 w-28 h-10 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-xs font-semibold text-foreground shadow shadow-black/20"
|
||||
style={{ left: "10%", top: "18%" }}
|
||||
onMouseEnter={() => setHoveredNode("roocode")}
|
||||
onMouseLeave={() => setHoveredNode(null)}
|
||||
onClick={() => setActiveClient(c => c === "roocode" ? null : "roocode")}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
<span>Roo Code</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute cursor-pointer select-none z-10 w-28 h-10 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-xs font-semibold text-foreground shadow shadow-black/20"
|
||||
style={{ left: "10%", top: "50%" }}
|
||||
onMouseEnter={() => setHoveredNode("cursor")}
|
||||
onMouseLeave={() => setHoveredNode(null)}
|
||||
onClick={() => setActiveClient(c => c === "cursor" ? null : "cursor")}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
<span>Cursor IDE</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute cursor-pointer select-none z-10 w-28 h-10 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-xs font-semibold text-foreground shadow shadow-black/20"
|
||||
style={{ left: "10%", top: "82%" }}
|
||||
onMouseEnter={() => setHoveredNode("opencode")}
|
||||
onMouseLeave={() => setHoveredNode(null)}
|
||||
onClick={() => setActiveClient(c => c === "opencode" ? null : "opencode")}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
<span>OpenCode</span>
|
||||
</div>
|
||||
|
||||
{/* COLUMN 2: Central Gateway Node */}
|
||||
<div
|
||||
className="absolute select-none z-10 w-36 py-3 px-4 -translate-y-1/2 -translate-x-1/2 rounded-2xl border border-primary/50 bg-card/90 backdrop-blur-md flex flex-col items-center justify-center text-center shadow-lg shadow-primary/5 select-none"
|
||||
style={{ left: "50%", top: "50%" }}
|
||||
>
|
||||
<div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway Auto</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
|
||||
Schwelle: > {routing?.heavy_threshold_chars ? (routing.heavy_threshold_chars / 1000) : "4"}k Zeichen
|
||||
</div>
|
||||
<div className="mt-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-semibold text-primary uppercase font-mono">
|
||||
Auto-Swap
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* COLUMN 3: Role nodes (fast, heavy, coder, vision, scout) */}
|
||||
{ROLES.map((role) => {
|
||||
const yPositions = ["12%", "31%", "50%", "69%", "88%"]
|
||||
const activeModel = getModelForRole(role)
|
||||
const isWarm = activeModel ? running.includes(activeModel.name) : false
|
||||
|
||||
// Skip "reasoning" & "agent" to keep layout neat and identical to canonical roles
|
||||
if (role === "reasoning" || role === "agent") return null
|
||||
const indexMap = { fast: 0, heavy: 1, coder: 2, vision: 3, scout: 4 }[role] as number
|
||||
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className={cn(
|
||||
"absolute z-10 w-44 py-1.5 px-3 -translate-y-1/2 -translate-x-1/2 rounded-xl border flex flex-col justify-center hover:border-primary/50 transition-all text-left shadow select-none cursor-pointer",
|
||||
isWarm
|
||||
? "border-emerald-500/50 bg-emerald-500/5 shadow-emerald-500/5"
|
||||
: activeModel
|
||||
? "border-border/60 bg-card/75"
|
||||
: "border-dashed border-border/40 bg-background/20"
|
||||
)}
|
||||
style={{ left: "90%", top: yPositions[indexMap] }}
|
||||
onClick={() => setActiveRoleDrop(activeRoleDrop === role ? null : role)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{role}</span>
|
||||
{isWarm && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono font-medium truncate mt-0.5 text-foreground max-w-[150px]">
|
||||
{activeModel ? activeModel.name.split("/").pop()?.replace(".gguf", "") : "Keine Zuweisung"}
|
||||
</div>
|
||||
|
||||
{/* Inline drop selection */}
|
||||
{activeRoleDrop === role && (
|
||||
<div className="absolute right-0 top-full mt-1 z-35 w-52 rounded-xl border border-border/80 bg-popover/95 backdrop-blur-md p-1.5 shadow-2xl space-y-1">
|
||||
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 px-2 py-1 select-none">Modell zuweisen:</div>
|
||||
<button
|
||||
onClick={() => handleRoleChange(role, "")}
|
||||
className="w-full text-left px-2.5 py-1.5 rounded-lg text-[10px] hover:bg-accent text-red-400 font-semibold cursor-pointer"
|
||||
>
|
||||
Zuweisung entfernen
|
||||
</button>
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => handleRoleChange(role, m.name)}
|
||||
className={cn(
|
||||
"w-full text-left px-2.5 py-1.5 rounded-lg text-[10px] hover:bg-accent flex items-center justify-between font-mono cursor-pointer",
|
||||
m.role === role ? "text-primary font-bold" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate max-w-[150px]">{m.name.split("/").pop()?.replace(".gguf", "")}</span>
|
||||
{m.role === role && <Check className="h-3 w-3 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* FLOATING CLIENT SETUP POPOVER WINDOW */}
|
||||
{activeClient && connectData && (
|
||||
<div
|
||||
className="absolute z-30 md:w-96 w-[90%] rounded-2xl border border-border/80 bg-card/95 backdrop-blur-xl p-4 shadow-2xl space-y-3 flex flex-col justify-between"
|
||||
style={{
|
||||
left: "22%",
|
||||
top: activeClient === "roocode" ? "8%" : activeClient === "cursor" ? "30%" : "48%",
|
||||
}}
|
||||
>
|
||||
{/* Popover Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-primary">
|
||||
{activeClient === "roocode" && "Roo Code Setup"}
|
||||
{activeClient === "cursor" && "Cursor Setup"}
|
||||
{activeClient === "opencode" && "OpenCode Setup"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setActiveClient(null)}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Popover Guide Steps */}
|
||||
<div className="text-[10px] text-muted-foreground leading-relaxed space-y-2">
|
||||
{activeClient === "roocode" && (
|
||||
<ul className="list-decimal pl-4 space-y-1">
|
||||
<li>Suche in VS Code nach der Erweiterung <strong>Roo Code</strong> und installiere sie.</li>
|
||||
<li>Wähle in den Roo Code Einstellungen: Provider: <strong>OpenAI Compatible</strong>.</li>
|
||||
<li>Füge das untenstehende JSON-Snippet in die <code>settings.json</code> ein.</li>
|
||||
</ul>
|
||||
)}
|
||||
{activeClient === "cursor" && (
|
||||
<ul className="list-decimal pl-4 space-y-1">
|
||||
<li>Öffne Cursor Settings ➔ <strong>Models</strong>.</li>
|
||||
<li>Deaktiviere Cloud-Modelle, klappe <strong>OpenAI API</strong> auf.</li>
|
||||
<li>Trage die Base URL unten ein und aktiviere das Modell <strong>auto</strong>.</li>
|
||||
</ul>
|
||||
)}
|
||||
{activeClient === "opencode" && (
|
||||
<ul className="list-decimal pl-4 space-y-1">
|
||||
<li>Öffne die <code>opencode.jsonc</code> Konfigurationsdatei.</li>
|
||||
<li>Ersetze den Provider-Eintrag unter <code>providers</code> mit dem Snippet unten.</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Snippet box */}
|
||||
{connectData.tools && (
|
||||
<div className="relative rounded-xl border border-border/40 bg-black/40 overflow-hidden mt-1 shrink-0">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/20 bg-black/20">
|
||||
<span className="text-[8px] font-mono text-muted-foreground">JSON Config</span>
|
||||
<button
|
||||
onClick={() => copySnippet(
|
||||
activeClient === "roocode" ? connectData.tools.cline.snippet :
|
||||
activeClient === "cursor" ? connectData.tools.cursor.snippet :
|
||||
connectData.tools.opencode.snippet
|
||||
)}
|
||||
className="text-[9px] font-semibold text-primary hover:text-primary-foreground flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-emerald-400" /> : <Copy className="h-3 w-3" />}
|
||||
<span>{copied ? "Kopiert" : "Kopieren"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 max-h-36 overflow-y-auto text-[9px] font-mono text-cyan-200/90 whitespace-pre scrollbar-thin select-text">
|
||||
<code>
|
||||
{activeClient === "roocode" && connectData.tools.cline.snippet}
|
||||
{activeClient === "cursor" && connectData.tools.cursor.snippet}
|
||||
{activeClient === "opencode" && connectData.tools.opencode.snippet}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZONE C: Library list cards & Upgrade Radar */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">Installierte Modell-Bibliothek</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.length === 0 ? (
|
||||
<div className="col-span-full text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center select-none">
|
||||
Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen.
|
||||
</div>
|
||||
) : (
|
||||
models.map((m) => {
|
||||
const isRunning = running.includes(m.name)
|
||||
const hasUpgrade = updates?.model_list.find((u) => u.role === m.role)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.name}
|
||||
className={cn(
|
||||
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-4 transition-all duration-300 hover:border-primary/40 group",
|
||||
isRunning ? "border-primary/45 shadow-primary/5" : "border-border/60"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xs font-bold tracking-tight text-foreground line-clamp-2 break-all" title={m.name}>
|
||||
{m.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[9px] font-mono text-muted-foreground bg-background/40 px-1.5 py-0.5 rounded border border-border/30">
|
||||
{m.quant || "GGUF"}
|
||||
</span>
|
||||
{isRunning && (
|
||||
<span className="flex items-center gap-1 text-[9px] font-semibold text-emerald-400 uppercase tracking-wider">
|
||||
<Activity className="h-3 w-3 animate-pulse" /> Warm
|
||||
</span>
|
||||
)}
|
||||
{m.role && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-mono text-primary font-bold uppercase">
|
||||
{m.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(m.name)}
|
||||
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100 shrink-0 cursor-pointer"
|
||||
title="Modell aus Config entfernen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/30 pt-3 flex flex-wrap gap-1">
|
||||
<CapsChips caps={m.capabilities} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px] font-mono text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20">
|
||||
<HardDrive className="h-3.5 w-3.5 text-primary/80" />
|
||||
<div>
|
||||
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Größe</div>
|
||||
<div className="text-foreground font-semibold">{fmtSize(m.size_bytes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSetCtx(m.name, m.ctx)}
|
||||
className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20 hover:border-primary/40 text-left transition-colors cursor-pointer"
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5 text-primary/80" />
|
||||
<div>
|
||||
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Kontext</div>
|
||||
<div className="text-foreground font-semibold">{fmtCtx(m.ctx)}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Smart Upgrade radar trigger banner */}
|
||||
{hasUpgrade && (
|
||||
<div className="p-3 bg-amber-500/5 border border-amber-500/30 rounded-xl space-y-2 flex flex-col justify-between shrink-0">
|
||||
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping" />
|
||||
<span>Upgrade verfügbar: {hasUpgrade.repo.split("/").pop()}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSmartUpgrade(hasUpgrade.repo, m.role!, m.quant || "Q4_K_M", m.capabilities.tools !== "no")}
|
||||
className="h-7 w-full flex items-center justify-center gap-1 rounded-lg bg-amber-500 text-black text-[10px] font-bold hover:bg-amber-400 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="h-3 w-3" /> Smart-Swap starten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -493,7 +897,7 @@ function Discover() {
|
||||
}
|
||||
|
||||
export function ModelsView() {
|
||||
const [tab, setTab] = useState<"installed" | "discover" | "routing">("installed")
|
||||
const [tab, setTab] = useState<"cockpit" | "discover">("cockpit")
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
@@ -502,12 +906,12 @@ export function ModelsView() {
|
||||
Modell-Zentrale
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verwalte installierte GGUFs, weise Systemrollen zu, lade neue Modelle oder konfiguriere das Gateway-Routing.
|
||||
Verwalte deine Modelle und passe das Gateway-Routing live über das interaktive Cockpit an.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 self-start">
|
||||
{(["installed", "discover", "routing"] as const).map((t) => (
|
||||
{(["cockpit", "discover"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
@@ -518,7 +922,7 @@ export function ModelsView() {
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t === "installed" ? "Bibliothek" : t === "discover" ? "Modelle finden" : "Gateway-Routing"}
|
||||
{t === "cockpit" ? "Cockpit" : "Modelle finden"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -527,9 +931,8 @@ export function ModelsView() {
|
||||
<JobsBar />
|
||||
|
||||
<div className="transition-all duration-300">
|
||||
{tab === "installed" ? <Installed /> : tab === "discover" ? <Discover /> : <RoutingView />}
|
||||
{tab === "cockpit" ? <Cockpit /> : <Discover />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Route, GitBranch, ArrowRight, Settings, AlertCircle } from "lucide-react"
|
||||
import { api, type RoutingResp } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function RoutingView() {
|
||||
const [data, setData] = useState<RoutingResp | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
api<RoutingResp>("/api/routing")
|
||||
.then(setData)
|
||||
.catch((e) => setError(String(e)))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
|
||||
Gateway & Routing
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Eingebauter OpenAI-Gateway für Vibe Coding & Hermes. Verwende den Endpunkt <code>model: auto</code>, um je nach Komplexität automatisch zwischen <code>fast</code> und <code>heavy</code> zu routen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
|
||||
Gateway-Konfiguration nicht lesbar ({error}).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="space-y-6">
|
||||
{/* Status HUD Card */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={cn(
|
||||
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
|
||||
data.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500"
|
||||
)} />
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-foreground">Gateway-Status</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">{data.endpoint || "Lokaler Proxy"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.heavy_threshold_chars && (
|
||||
<div className="p-3 bg-background/25 rounded-xl border border-border/30 text-right">
|
||||
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Auto-Routing-Schwelle</div>
|
||||
<div className="text-xs font-semibold text-primary mt-0.5 font-mono">
|
||||
> {data.heavy_threshold_chars.toLocaleString()} Zeichen ➔ heavy
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual Routing flow */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{/* Box 1: Ingress */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 flex flex-col justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Settings className="h-4.5 w-4.5 text-primary" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">1. API Ingress</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Deine IDE oder dein Agent sendet Anfragen mit <code>model: auto</code> an den lokalen Gateway-Port.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/20 font-mono text-[10px]">
|
||||
<div className="text-muted-foreground/60">HEADER</div>
|
||||
<div className="text-primary truncate">Authorization: Bearer key</div>
|
||||
<div className="text-muted-foreground/60 mt-1">MODEL</div>
|
||||
<div className="text-foreground">"auto"</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box 2: Routing Logic */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 flex flex-col justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Route className="h-4.5 w-4.5 text-primary" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">2. Analysator</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Der Gateway misst die Länge des Prompts. Kurze Tasks landen bei <code>fast</code>, anspruchsvolle Tasks werden an <code>heavy</code> weitergeleitet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 font-mono text-[9px] p-2 bg-background/10 rounded-xl border border-border/10">
|
||||
<div className="flex items-center justify-between text-cyan-400">
|
||||
<span>Prompt < {data.heavy_threshold_chars}</span>
|
||||
<span className="flex items-center gap-1">Fast-Hirn <ArrowRight className="h-3 w-3" /></span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-violet-400">
|
||||
<span>Prompt >= {data.heavy_threshold_chars}</span>
|
||||
<span className="flex items-center gap-1">Heavy-Hirn <ArrowRight className="h-3 w-3" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box 3: Execution */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 flex flex-col justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="h-4.5 w-4.5 text-primary" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">3. Llama Swap</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Llama Swap tauscht das Modell bei Bedarf vollautomatisch im VRAM aus (Auto-Swap). Keine manuelle Zuweisung nötig.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-primary/5 rounded-xl border border-primary/20 text-center">
|
||||
<span className="text-[10px] font-bold text-primary animate-pulse">Auto-Swap aktiv</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Routes Cards */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">Gateway-Routen</h3>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{data.routes.map((r) => (
|
||||
<div
|
||||
key={r.name}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card/45 backdrop-blur-md hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-bold text-foreground font-mono">{r.name}</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase">Gateway-Alias</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
<div className="px-2.5 py-1 rounded-lg bg-background/40 border border-border/40 text-[10px] font-mono text-primary font-semibold truncate max-w-[160px] sm:max-w-[200px]" title={r.target}>
|
||||
{r.target}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallback Rules */}
|
||||
{data.fallbacks.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5 px-1">
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Eskalationspfad (Fallbacks)</h3>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 space-y-3">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Sollte ein angefordertes Modell offline oder überlastet sein, eskaliert das Routing sequenziell entlang dieser vordefinierten Kette:
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.fallbacks.map((f, i) => {
|
||||
const [k, v] = Object.entries(f)[0]
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-wrap items-center gap-2 p-2.5 bg-background/25 rounded-xl border border-border/20 font-mono text-xs"
|
||||
>
|
||||
<span className="font-semibold text-amber-400">{k}</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground/60" />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{v.map((item, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 rounded text-[10px]",
|
||||
idx === 0 ? "bg-primary/10 text-primary border border-primary/20" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user