import { useState } from "react" import { Download, Trash2, Edit3, Activity, HardDrive, Check, Zap, Bot } from "lucide-react" import { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api" import { useModels, useGroups, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries" import { useDialog } from "@/lib/useDialog" import { CapsChips } from "@/components/CapsChips" import { cn } from "@/lib/utils" import { fmtSize, fmtCtx } from "@/lib/format" import { getBrandInfo, ROLES, RoleLabel, roleMeta } from "@/components/models/ModelBadges" import { SpecDraftModal } from "@/components/models/SpecDraftModal" import { RoleAssignModal } from "./cockpit/RoleAssignModal" import { LaneEditor } from "@/components/models/LaneEditor" import { WarmSetManager } from "@/components/models/WarmSetManager" import { useExpertMode } from "@/lib/useExpertMode" export function Cockpit() { const qc = useQueryClient() const { data: modelsResp, isLoading: loading, error: loadErr } = useModels(4_000) const { data: updates } = useUpdates(4_000) const { data: brain } = useHermesBrain() const { data: sysStatus } = useSystemStatus() const { data: groupsResp } = useGroups() const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog() const expert = useExpertMode() // Schutzgeländer: Modelle in einer lebenswichtigen Rolle (Hirn/Gedächtnis) dürfen // nicht gelöscht/entladen werden. Das `protected`-Flag kommt zentral aus roleMeta. const isProtected = (role?: string | null) => !!roleMeta(role).protected const models = modelsResp?.models ?? [] const running = modelsResp?.running ?? [] const error = loadErr ? String(loadErr) : "" const reload = () => { qc.invalidateQueries({ queryKey: qk.models }) qc.invalidateQueries({ queryKey: qk.routing }) } // Ko-Residenz-Gruppe „brains": ihre Mitglieder dürfen gleichzeitig resident bleiben // (Hirn + Augen verdrängen sich nicht). Ein Modell außerhalb der Gruppe lädt exklusiv. const brainsGroup = groupsResp?.groups?.brains const brainsMembers = brainsGroup?.members ?? [] const isCoresident = (name: string) => brainsMembers.includes(name) // Ist gerade ein warmes Hirn-Mitglied resident, das ein gruppenloses Lade-Modell verdrängen würde? const brainsWarm = brainsMembers.some((mname) => running.includes(mname)) async function toggleCoresident(name: string) { if (!brainsGroup) { showAlert("Keine brains-Gruppe", "Es existiert noch keine Ko-Residenz-Gruppe „brains“ in der Engine-Konfiguration. Lege sie erst über die Gruppen-Verwaltung an.") return } const inGroup = brainsMembers.includes(name) const next = inGroup ? brainsMembers.filter((x) => x !== name) : [...brainsMembers, name] try { await setGroup("brains", next, brainsGroup.swap ?? false, brainsGroup.persist ?? true) qc.invalidateQueries({ queryKey: qk.groups }) reload() } catch (e: any) { showAlert("Fehler", `Ko-Residenz konnte nicht geändert werden: ${e.message || e}`) } } // UI state const [activeRoleForAssign, setActiveRoleForAssign] = useState(null) const [roleRec, setRoleRec] = useState(null) const [specModel, setSpecModel] = useState(null) const [viewMode, setViewMode] = useState<"grid" | "list">("grid") const [filterMode, setFilterMode] = useState<"all" | "in_use">("all") const filteredModels = models.filter((m) => { if (filterMode === "in_use") { return !!m.role || running.includes(m.name) } return true }) async function doLoadModel(name: string) { try { await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" }) reload() } catch (e: any) { showAlert("Fehler", `Fehler beim Laden des Modells: ${e.message}`) } } async function handleLoadModel(name: string) { // Warnung: Ein gruppenloses Modell lädt exklusiv und verdrängt das warme Hirn (brains-Ko-Residenz). if (brainsGroup && brainsWarm && !isCoresident(name)) { const warm = brainsMembers.filter((m) => running.includes(m)).map((m) => m.split("/").pop()).join(", ") showConfirm( "Verdrängt das Hirn?", `„${name.split("/").pop()}“ ist nicht in der Ko-Residenz-Gruppe „brains“. Beim Laden wirft es das aktuell warme Hirn (${warm}) raus — Lucy verliert Hirn bzw. Augen.\n\nTrotzdem exklusiv laden? Tipp: Mit dem 🧠-Schalter machst du das Modell dauerhaft ko-resident, dann bleiben beide warm.`, () => doLoadModel(name), ) return } void doLoadModel(name) } async function handleUnloadModel(name: string) { const m = models.find((x) => x.name === name) if (m && isProtected(m.role)) { showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig für Lucy und bleibt geladen. Zum Entladen erst im Experten-Modus die Rolle einem anderen Modell geben.`) return } try { await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" }) reload() } catch (e: any) { showAlert("Fehler", `Fehler beim Entladen des Modells: ${e.message}`) } } async function handleUnloadAll() { try { await api("/api/models/unload", { method: "POST" }) reload() } catch (e: any) { showAlert("Fehler", `Fehler beim Entladen aller Modelle: ${e.message}`) } } async function handleRoleChange(role: string, modelName: string) { try { // Bei der Hirn-Rolle (hermes) läuft serverseitig der warm-bewusste Flow (Alias + brains- // Gruppe + ttl 0 + Hermes-Config + Gateway-Restart) und liefert ggf. eine Budget-Warnung. const res = await api<{ warning?: string | null }>(`/api/models/${encodeURIComponent(modelName)}/role`, { method: "POST", body: JSON.stringify({ role: role || null }), }) reload() if (res?.warning) showAlert("Hirn gesetzt — Hinweis", res.warning) } catch (e: any) { showAlert("Fehler", `Fehler beim Zuweisen der Rolle: ${e.message || e}`) } } // Rollen-Modal öffnen + setup-bewusste Empfehlung holen (bestes installiertes Modell). function openRoleAssign(role: string) { setActiveRoleForAssign(role) setRoleRec(null) api(`/api/roles/${encodeURIComponent(role)}/recommend`) .then((d) => setRoleRec(d)) .catch(() => { /* Empfehlung optional — Modal funktioniert auch ohne */ }) } async function handleSetCtx(name: string, cur: number | null) { // Setup-bewussten Optimalwert holen (Rolle/Params/Quant + aktuelles Setup). let auto: { ctx: number; gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string } | null = null try { auto = await api(`/api/models/${encodeURIComponent(name)}/ctx/auto`) } catch { /* Auto optional — Prompt funktioniert auch ohne */ } const hint = auto ? `Optimal für dein Setup: ${(auto.ctx / 1024).toFixed(0)}k (${auto.ctx}) — ` + `GTT ${auto.gtt_gb} GB − reserviert ${auto.reserved_gb} GB (${auto.mode}) → ${auto.budget_gb} GB frei. ` + `»Auto« trägt diesen Wert ein.` : "Gib die gewünschte Kontextlänge in Tokens an:" showPrompt( "Kontextlänge anpassen", hint, String(cur || 32768), async (v) => { if (!v) return try { await api(`/api/models/${encodeURIComponent(name)}/ctx`, { method: "POST", body: JSON.stringify({ ctx: parseInt(v, 10) }), }) reload() } catch (e: any) { showAlert("Fehler", `Fehler beim Setzen des Kontexts: ${e.message || e}`) } }, undefined, auto ? { autoValue: String(auto.ctx), autoLabel: `Auto (${(auto.ctx / 1024).toFixed(0)}k)` } : undefined, ) } async function handleDelete(name: string) { const m = models.find((x) => x.name === name) if (m && isProtected(m.role)) { showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig für Lucy und kann nicht gelöscht werden. Weise die Rolle zuerst einem anderen Modell zu.`) return } showConfirm( "Modell löschen?", `Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`, async () => { try { await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" }) reload() } catch (e: any) { showAlert("Fehler", `Fehler beim Löschen: ${e.message || e}`) } } ) } 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 }), }) showAlert("Herunterladen gestartet", `Download für '${repo}' gestartet! Der Fortschritt wird oben angezeigt.`) } catch (e: any) { showAlert("Fehler", `Fehler beim Starten des Upgrades: ${e.message || e}`) } } async function handleBrainUpdate(repo: string) { const b = brain?.budget const warn = b && !b.fits ? `\n\n⚠ Speicher-Warnung: Dieses Brain (~${b.brain_gb} GB) bleibt immer resident (persistent). Zusammen mit dem größten on-demand-Modell (~${b.largest_ondemand_gb} GB) sprengt das das Budget (${b.gtt_gb} GB) → beim Laden von heavy/coder droht ein Überlauf. Erwäge ein kleineres Brain oder weniger Kontext.` : "" showConfirm( "Agent-Hirn aktualisieren?", `Neues Hermes-Modell '${repo.split("/").pop()}' herunterladen und als Agent-Hirn (Rolle hermes) setzen? Hermes nutzt danach automatisch das neue Modell.${warn}`, async () => { try { await api("/api/models/install", { method: "POST", body: JSON.stringify({ repo, role: "hermes", quant: "Q4_K_M", jinja: true }), }) showAlert("Download gestartet", "Das neue Agent-Hirn wird geladen. Fortschritt oben.") reload() } catch (e: any) { showAlert("Fehler", `Update fehlgeschlagen: ${e.message || e}`) } } ) } if (loading) return
Initialisiere HUD Cockpit…
if (error) { return (
Gateway oder Engine nicht erreichbar ({error}).
) } // Speicher-Pool = echtes GTT-Budget (unified memory, ~124 GB), nicht der alte 16-GB-Fallback. const runningWithInfo = models.filter((m) => running.includes(m.name)) const totalRunningSize = runningWithInfo.reduce((acc, m) => acc + (m.size_bytes || 0), 0) const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0 const gttUsed = sysStatus?.gpu?.gtt_used || 0 const virtualMax = 16 * 1024 ** 3 // Fallback, falls sysfs nichts liefert const capacity = gttTotal > 2 * 1024 ** 3 ? gttTotal : (totalRunningSize > virtualMax ? totalRunningSize * 1.2 : virtualMax) return (
{/* Styles inside Cockpit for dash flow animations */} {/* ZONE A: Widescreen VRAM HUD */}
VRAM / Memory Belegung
Speicher-Pool: {fmtSize(totalRunningSize)} Gewichte{gttUsed > 0 ? ` · ${fmtSize(gttUsed)} real belegt (inkl. KV)` : ""} / {fmtSize(capacity)} {expert && running.length > 0 && ( )}
{/* The memory bar */}
{runningWithInfo.length === 0 ? (
VRAM Leer — Auto-Swap lädt Modelle bei Anfrage
) : ( 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 (
{m.role ? `[${roleMeta(m.role).short}] ` : ""}{m.name.split("/").pop()?.replace(".gguf", "")} {fmtSize(m.size_bytes)}
) }) )}
{/* ZONE B.3: Immer-bereit-Set (Warm-Set / brains-Gruppe) — in Klartext, mit Budget */} {/* ZONE B.4: Lane-Routing & Policy (UI-editierbar, hot-reload) — Experten-Detail */} {expert && } {/* ZONE B.5: Slot-Belegung */}
{expert ? "Gateway Steckplatz-Belegung (Slot-Zuweisung)" : "Wer macht was bei Lucy — zum Ändern antippen"}
{ROLES.map((role) => { const m = models.find((x) => x.role === role) const isWarm = m ? running.includes(m.name) : false return (
openRoleAssign(role)} className={cn( "rounded-xl border p-3 flex flex-col justify-between gap-2.5 transition-all cursor-pointer select-none text-left bg-background/25 hover:border-primary/45 hover:bg-background/40 group min-h-[90px]", isWarm ? "border-emerald-500/40 shadow-sm shadow-emerald-500/5" : m ? "border-primary/20 bg-primary/5" : "border-border/30 border-dashed opacity-75" )} >
{isWarm && }
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
Ändern ➔
) })}
{/* ZONE B.6: Agent-Hirn (Hermes) — sichtbar + updatebar (NousResearch) */} {brain?.current && (
Agent-Hirn (Hermes) {brain.current.version != null && ( v{brain.current.version} )}
{brain.current.name.split("/").pop()}
{brain.current.params_b ? `${brain.current.params_b}B` : "—"} {brain.current.quant || "GGUF"} {fmtSize(brain.current.size_bytes || 0)}
{brain.update_available && brain.recommended ? ( ) : ( Neueste Generation )}
{brain.update_available && brain.recommended && (
Neuere Generation verfügbar: {brain.recommended.name.replace(/-GGUF$/i, "")} (v{brain.recommended.version}, {brain.recommended.params_b}B) — von NousResearch.
)} {brain.budget && (
Always-On-Brain ~{brain.budget.brain_gb} GB + größtes on-demand (~{brain.budget.largest_ondemand_gb} GB) = {(brain.budget.brain_gb + brain.budget.largest_ondemand_gb).toFixed(1)} / {brain.budget.gtt_gb} GB {brain.budget.fits ? " · passt gleichzeitig ✓" : " · passt NICHT gleichzeitig ⚠ — heavy/coder würden das Brain verdrängen. Kleineres Brain oder weniger Kontext."}
)}
)} {/* ZONE C: Library list cards & Upgrade Radar */}
Installierte Modell-Bibliothek ({filteredModels.length} von {models.length})
{/* Filter mode selector */}
{/* Grid/List layout selector */}
{viewMode === "grid" ? (
{filteredModels.length === 0 ? (
{filterMode === "in_use" ? "Keine Modelle in Benutzung (keine Zuweisung oder warm geladene Modelle)." : "Keine Modelle konfiguriert. Verwende den Tab \"Modelle finden\" zum Herunterladen."}
) : ( filteredModels.map((m) => { const isRunning = running.includes(m.name) const hasUpgrade = updates?.model_list.find((u) => u.role === m.role) const brand = getBrandInfo(m.name) return (
{/* Brand Icon Badge */}
{brand.initial}

{m.name.split("/").pop()}

{m.quant || "GGUF"} {isRunning && ( Warm )} {m.role && } {isProtected(m.role) && ( 🔒 geschützt )} {expert && isCoresident(m.name) && ( 🧠 Ko-resident )} {expert && m.prompt_cache && ( PC )} {expert && (m.spec_active ? ( SPEC ) : m.spec_draft_model ? ( SPEC? ) : null)} {expert && m.parallel_slots > 1 && ( SLOTS: {m.parallel_slots} )} {m.incomplete && ( ⚠ Datei fehlt )}
Größe
{fmtSize(m.size_bytes)}
Kontext
{fmtCtx(m.ctx)}
{/* Smart Upgrade radar trigger banner */} {hasUpgrade && (
Upgrade verfügbar: {hasUpgrade.repo.split("/").pop()}
)} {/* Actions row at the bottom of Grid Card */}
{(() => { const prot = isProtected(m.role) const lockUnload = isRunning && prot const disabled = (m.incomplete && !isRunning) || lockUnload return ( ) })()} {expert && ( )} {expert && brainsGroup && ( )} {expert && ( )}
) }) )}
) : (
{filteredModels.length === 0 ? (
{filterMode === "in_use" ? "Keine Modelle in Benutzung (keine Zuweisung oder warm geladene Modelle)." : "Keine Modelle konfiguriert. Verwende den Tab \"Modelle finden\" zum Herunterladen."}
) : ( filteredModels.map((m) => { const isRunning = running.includes(m.name) const brand = getBrandInfo(m.name) return (
{/* Brand Icon */}
{brand.initial}

{m.name.split("/").pop()}

{m.role && } {isProtected(m.role) && ( 🔒 geschützt )} {expert && isCoresident(m.name) && ( 🧠 Ko-resident )} {expert && m.prompt_cache && ( PC )} {expert && (m.spec_active ? ( SPEC ) : m.spec_draft_model ? ( SPEC? ) : null)} {expert && m.parallel_slots > 1 && ( SLOTS: {m.parallel_slots} )} {m.incomplete && ( ⚠ Datei fehlt )} {isRunning && ( warm )}
Größe: {fmtSize(m.size_bytes)} Kontext: {fmtCtx(m.ctx)} {m.quant || "GGUF"}
{/* Chips and Actions */}
{(() => { const prot = isProtected(m.role) const lockUnload = isRunning && prot const disabled = (m.incomplete && !isRunning) || lockUnload return ( ) })()} {expert && ( )} {expert && brainsGroup && ( )} {expert && ( )}
) }) )}
)}
{/* Role Assignment Modal */} {activeRoleForAssign && ( { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }} onClose={() => setActiveRoleForAssign(null)} /> )} {specModel && ( setSpecModel(null)} onChanged={reload} /> )} {dialogElement}
) }