feat: upgrade Modell-Zentrale to Model Control HUD 3.0, delete obsolete RoutingView

This commit is contained in:
Hitonabi
2026-06-25 22:56:13 +02:00
parent 97f8bc34bc
commit f1e0503c73
7 changed files with 856 additions and 660 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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: &gt; {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>
)
}
-192
View File
@@ -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 &amp; Routing
</h1>
<p className="text-sm text-muted-foreground">
Eingebauter OpenAI-Gateway für Vibe Coding &amp; 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">
&gt; {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 &lt; {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 &gt;= {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>
)
}