Files
mission-control-v2/frontend/src/views/models/Cockpit.tsx
T

909 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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<string | null>(null)
const [roleRec, setRoleRec] = useState<RoleRecResp | null>(null)
const [specModel, setSpecModel] = useState<ModelInfo | null>(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<RoleRecResp>(`/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 <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">
Gateway oder Engine nicht erreichbar ({error}).
</div>
)
}
// 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 (
<div className="space-y-8">
{/* Styles inside Cockpit for dash flow animations */}
<style>{`
@keyframes flow-dash {
to {
stroke-dashoffset: -20;
}
}
.svg-flow-path {
stroke-dasharray: 4 6;
animation: flow-dash 1s 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>
<div className="flex items-center gap-3">
<span className="text-[10px] font-mono text-muted-foreground">
Speicher-Pool: {fmtSize(totalRunningSize)} Gewichte{gttUsed > 0 ? ` · ${fmtSize(gttUsed)} real belegt (inkl. KV)` : ""} / {fmtSize(capacity)}
</span>
{expert && running.length > 0 && (
<button
onClick={handleUnloadAll}
className="h-6 px-2.5 rounded border border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10 text-amber-400 text-[9px] font-bold uppercase transition-all cursor-pointer"
>
Alle entladen
</button>
)}
</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 ? `[${roleMeta(m.role).short}] ` : ""}{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.3: Immer-bereit-Set (Warm-Set / brains-Gruppe) — in Klartext, mit Budget */}
<WarmSetManager />
{/* ZONE B.4: Lane-Routing & Policy (UI-editierbar, hot-reload) — Experten-Detail */}
{expert && <LaneEditor />}
{/* ZONE B.5: Slot-Belegung */}
<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="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">
{expert ? "Gateway Steckplatz-Belegung (Slot-Zuweisung)" : "Wer macht was bei Lucy — zum Ändern antippen"}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{ROLES.map((role) => {
const m = models.find((x) => x.role === role)
const isWarm = m ? running.includes(m.name) : false
return (
<div
key={role}
onClick={() => 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"
)}
>
<div className="flex items-center justify-between">
<RoleLabel role={role} />
{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 text-foreground/90 pr-1 font-space" title={m?.name}>
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
</div>
<div className="text-[8px] text-primary/70 group-hover:text-primary font-bold uppercase tracking-wider transition-colors font-space">
Ändern
</div>
</div>
)
})}
</div>
</div>
{/* ZONE B.6: Agent-Hirn (Hermes) — sichtbar + updatebar (NousResearch) */}
{brain?.current && (
<div className="rounded-2xl border border-indigo-500/30 bg-indigo-500/5 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-indigo-400" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Agent-Hirn (Hermes)</span>
{brain.current.version != null && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-indigo-500/15 text-indigo-300 border border-indigo-500/25">v{brain.current.version}</span>
)}
</div>
<button
onClick={() => window.dispatchEvent(new CustomEvent("mc-navigate", { detail: { view: "agent" } }))}
className="h-7 px-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5 text-indigo-300 text-[9px] font-bold uppercase hover:bg-indigo-500/15 transition-all cursor-pointer"
title="Hirn-Wechsel passiert jetzt zentral im Hermes-Tab"
>
Im Hermes-Tab wechseln
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl bg-background/30 border border-border/30 p-3.5">
<div className="min-w-0">
<div className="text-xs font-mono font-bold text-foreground truncate" title={brain.current.name}>
{brain.current.name.split("/").pop()}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 mt-0.5">
<span>{brain.current.params_b ? `${brain.current.params_b}B` : "—"}</span>
<span></span><span>{brain.current.quant || "GGUF"}</span>
<span></span><span>{fmtSize(brain.current.size_bytes || 0)}</span>
</div>
</div>
{brain.update_available && brain.recommended ? (
<button
onClick={() => handleBrainUpdate(brain.recommended!.repo)}
className={cn(
"h-8 px-3 shrink-0 flex items-center justify-center gap-1.5 rounded-lg text-[10px] font-bold uppercase transition-all cursor-pointer shadow-md",
brain.budget && !brain.budget.fits
? "bg-red-500 text-white hover:bg-red-400 shadow-red-500/10"
: "bg-amber-500 text-black hover:bg-amber-400 shadow-amber-500/10"
)}
>
<Download className="h-3.5 w-3.5" /> Aktualisieren
</button>
) : (
<span className="h-8 px-3 shrink-0 flex items-center gap-1.5 text-[10px] font-bold text-emerald-400 uppercase tracking-wide bg-emerald-500/5 border border-emerald-500/15 rounded-lg select-none">
<Check className="h-4 w-4" /> Neueste Generation
</span>
)}
</div>
{brain.update_available && brain.recommended && (
<div className="text-[10px] text-amber-400/90 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
Neuere Generation verfügbar: <span className="font-mono font-bold">{brain.recommended.name.replace(/-GGUF$/i, "")}</span>
(v{brain.recommended.version}, {brain.recommended.params_b}B) von NousResearch.
</div>
)}
{brain.budget && (
<div className={cn("text-[10px] flex items-start gap-1.5 leading-relaxed",
brain.budget.fits ? "text-muted-foreground/80" : "text-red-400 font-semibold")}>
<HardDrive className="h-3 w-3 shrink-0 mt-0.5" />
<span>
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."}
</span>
</div>
)}
</div>
)}
{/* ZONE C: Library list cards & Upgrade Radar */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-1">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Installierte Modell-Bibliothek ({filteredModels.length} von {models.length})
</div>
<div className="flex items-center gap-3">
{/* Filter mode selector */}
<div className="flex rounded-lg border border-border/40 bg-card/45 p-0.5">
<button
onClick={() => setFilterMode("all")}
className={cn(
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
filterMode === "all" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
Vorhanden
</button>
<button
onClick={() => setFilterMode("in_use")}
className={cn(
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
filterMode === "in_use" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
In Benutzung
</button>
</div>
{/* Grid/List layout selector */}
<div className="flex rounded-lg border border-border/40 bg-card/45 p-0.5">
<button
onClick={() => setViewMode("grid")}
className={cn(
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
viewMode === "grid" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
Grid
</button>
<button
onClick={() => setViewMode("list")}
className={cn(
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
viewMode === "list" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
List
</button>
</div>
</div>
</div>
{viewMode === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredModels.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">
{filterMode === "in_use"
? "Keine Modelle in Benutzung (keine Zuweisung oder warm geladene Modelle)."
: "Keine Modelle konfiguriert. Verwende den Tab \"Modelle finden\" zum Herunterladen."}
</div>
) : (
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 (
<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 relative overflow-hidden",
isRunning
? "border-primary/45 shadow-primary/5"
: m.role
? "border-primary/30 bg-primary/5 shadow-inner"
: "border-border/60"
)}
>
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex gap-2.5 min-w-0">
{/* Brand Icon Badge */}
<div className={cn("w-7 h-7 rounded-lg border flex items-center justify-center font-bold text-xs shrink-0 shadow-sm", brand.color)} title={brand.name}>
{brand.initial}
</div>
<div className="min-w-0">
<h3 className="text-xs font-bold tracking-tight text-foreground line-clamp-2 break-all font-mono" title={m.name}>
{m.name.split("/").pop()}
</h3>
<div className="flex items-center gap-2 flex-wrap mt-1">
<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 && <RoleLabel role={m.role} dense />}
{isProtected(m.role) && (
<span className="px-1.5 py-0.5 rounded bg-background/40 border border-border/40 text-[9px] font-semibold text-muted-foreground" title="Lebenswichtig für Lucy — geschützt vor Löschen/Entladen.">
🔒 geschützt
</span>
)}
{expert && isCoresident(m.name) && (
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[9px] font-mono text-fuchsia-300 font-bold uppercase" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm, verdrängt es nicht.">
🧠 Ko-resident
</span>
)}
{expert && m.prompt_cache && (
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[9px] font-mono text-cyan-400 font-bold uppercase" title="Prompt Caching aktiv">
PC
</span>
)}
{expert && (m.spec_active ? (
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[9px] font-mono text-emerald-400 font-bold uppercase" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
SPEC
</span>
) : m.spec_draft_model ? (
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-mono text-amber-400 font-bold uppercase" title={`Draft gesetzt (${m.spec_draft_model}), aber INAKTIV — --spec-type fehlt oder Vocab inkompatibel. Über 'Spec' neu konfigurieren.`}>
SPEC?
</span>
) : null)}
{expert && m.parallel_slots > 1 && (
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[9px] font-mono text-violet-400 font-bold uppercase" title={`${m.parallel_slots} parallele Slots aktiv`}>
SLOTS: {m.parallel_slots}
</span>
)}
{m.incomplete && (
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-mono text-amber-400 font-bold uppercase" title="GGUF-Datei fehlt — Modell kann nicht geladen werden">
Datei fehlt
</span>
)}
</div>
</div>
</div>
</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>
<div className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20">
<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>
</div>
</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>
)}
{/* Actions row at the bottom of Grid Card */}
<div className="flex items-center gap-1.5 border-t border-border/20 pt-3 mt-auto">
{(() => {
const prot = isProtected(m.role)
const lockUnload = isRunning && prot
const disabled = (m.incomplete && !isRunning) || lockUnload
return (
<button
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
disabled={disabled}
title={lockUnload ? `${roleMeta(m.role).label}" bleibt geladen (geschützt).` : undefined}
className={cn(
"flex-1 h-7 rounded-lg text-[9px] font-bold uppercase transition-all border flex items-center justify-center gap-1",
disabled
? "border-border/30 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
: isRunning
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400 cursor-pointer"
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary cursor-pointer"
)}
>
{lockUnload ? "🔒 geladen" : isRunning ? "Entladen" : "Laden"}
</button>
)
})()}
{expert && (
<button
onClick={() => handleSetCtx(m.name, m.ctx)}
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
title="Kontextlänge anpassen"
>
Ctx
</button>
)}
{expert && brainsGroup && (
<button
onClick={() => toggleCoresident(m.name)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-colors cursor-pointer",
isCoresident(m.name)
? "border-fuchsia-500/40 bg-fuchsia-500/10 text-fuchsia-300 hover:bg-fuchsia-500/20"
: "border-border/40 text-muted-foreground hover:bg-accent"
)}
title={isCoresident(m.name)
? "Ko-resident (brains): bleibt mit dem Hirn gemeinsam warm. Klick = aus der Gruppe nehmen."
: "Zur brains-Gruppe hinzufügen — lädt dann ko-resident neben dem Hirn, statt es zu verdrängen."}
>
🧠
</button>
)}
{expert && (
<button
onClick={() => setSpecModel(m)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-colors cursor-pointer flex items-center gap-1",
m.spec_active ? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
: m.spec_draft_model ? "border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
: "border-border/40 text-muted-foreground hover:bg-accent"
)}
title="Speculative Draft konfigurieren (Vocab-geprüft)"
>
<Zap className="h-3 w-3" /> Spec
</button>
)}
<button
onClick={() => handleDelete(m.name)}
disabled={isProtected(m.role)}
className={cn(
"h-7 w-7 rounded-lg border border-border/40 flex items-center justify-center transition-colors",
isProtected(m.role)
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:text-red-400 hover:bg-red-500/5 cursor-pointer"
)}
aria-label="Modell löschen"
title={isProtected(m.role) ? `${roleMeta(m.role).label}" ist geschützt und kann nicht gelöscht werden.` : "Modell löschen"}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
</div>
)
})
)}
</div>
) : (
<div className="space-y-2">
{filteredModels.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center select-none">
{filterMode === "in_use"
? "Keine Modelle in Benutzung (keine Zuweisung oder warm geladene Modelle)."
: "Keine Modelle konfiguriert. Verwende den Tab \"Modelle finden\" zum Herunterladen."}
</div>
) : (
filteredModels.map((m) => {
const isRunning = running.includes(m.name)
const brand = getBrandInfo(m.name)
return (
<div
key={m.name}
className={cn(
"rounded-xl border bg-card/45 backdrop-blur-md p-3.5 shadow-lg shadow-black/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:border-primary/40 transition-all",
isRunning
? "border-primary/45"
: m.role
? "border-primary/30 bg-primary/5"
: "border-border/60"
)}
>
<div className="flex items-center gap-3 min-w-0">
{/* Brand Icon */}
<div className={cn("w-8 h-8 rounded-lg border border-border/40 flex items-center justify-center font-bold text-xs shrink-0 shadow-sm", brand.color)} title={brand.name}>
{brand.initial}
</div>
<div className="min-w-0 text-left">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-xs font-bold text-foreground truncate max-w-[250px] sm:max-w-[400px] break-all font-mono" title={m.name}>
{m.name.split("/").pop()}
</h4>
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
{isProtected(m.role) && (
<span className="px-1.5 py-0.5 rounded bg-background/40 border border-border/40 text-[8px] font-semibold text-muted-foreground shrink-0" title="Lebenswichtig für Lucy — geschützt vor Löschen/Entladen.">
🔒 geschützt
</span>
)}
{expert && isCoresident(m.name) && (
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[8px] font-mono text-fuchsia-300 font-bold uppercase shrink-0" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm.">
🧠 Ko-resident
</span>
)}
{expert && m.prompt_cache && (
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[8px] font-mono text-cyan-400 font-bold uppercase shrink-0" title="Prompt Caching aktiv">
PC
</span>
)}
{expert && (m.spec_active ? (
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[8px] font-mono text-emerald-400 font-bold uppercase shrink-0" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
SPEC
</span>
) : m.spec_draft_model ? (
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[8px] font-mono text-amber-400 font-bold uppercase shrink-0" title={`Draft gesetzt (${m.spec_draft_model}), aber INAKTIV — --spec-type fehlt oder Vocab inkompatibel.`}>
SPEC?
</span>
) : null)}
{expert && m.parallel_slots > 1 && (
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[8px] font-mono text-violet-400 font-bold uppercase shrink-0" title={`${m.parallel_slots} parallele Slots aktiv`}>
SLOTS: {m.parallel_slots}
</span>
)}
{m.incomplete && (
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[8px] font-mono text-amber-400 font-bold uppercase shrink-0" title="GGUF-Datei fehlt">
Datei fehlt
</span>
)}
{isRunning && (
<span className="flex items-center gap-0.5 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> warm
</span>
)}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 mt-0.5">
<span>Größe: {fmtSize(m.size_bytes)}</span>
<span></span>
<span>Kontext: {fmtCtx(m.ctx)}</span>
<span></span>
<span className="font-mono text-[9px]">{m.quant || "GGUF"}</span>
</div>
</div>
</div>
{/* Chips and Actions */}
<div className="flex items-center gap-3 shrink-0 self-end sm:self-auto">
<div className="hidden lg:flex flex-wrap gap-1">
<CapsChips caps={m.capabilities} />
</div>
<div className="flex items-center gap-1.5">
{(() => {
const prot = isProtected(m.role)
const lockUnload = isRunning && prot
const disabled = (m.incomplete && !isRunning) || lockUnload
return (
<button
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
disabled={disabled}
title={lockUnload ? `${roleMeta(m.role).label}" bleibt geladen (geschützt).` : undefined}
className={cn(
"h-7 px-3 rounded-lg text-[9px] font-bold uppercase transition-all border flex items-center gap-1",
disabled
? "border-border/30 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
: isRunning
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400 cursor-pointer"
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary cursor-pointer"
)}
>
{lockUnload ? "🔒 geladen" : isRunning ? "Entladen" : "Laden"}
</button>
)
})()}
{expert && (
<button
onClick={() => handleSetCtx(m.name, m.ctx)}
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-all cursor-pointer"
title="Kontextlänge anpassen"
>
Ctx
</button>
)}
{expert && brainsGroup && (
<button
onClick={() => toggleCoresident(m.name)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-colors cursor-pointer",
isCoresident(m.name)
? "border-fuchsia-500/40 bg-fuchsia-500/10 text-fuchsia-300 hover:bg-fuchsia-500/20"
: "border-border/40 text-muted-foreground hover:bg-accent"
)}
title={isCoresident(m.name)
? "Ko-resident (brains): bleibt mit dem Hirn gemeinsam warm. Klick = aus der Gruppe nehmen."
: "Zur brains-Gruppe hinzufügen — lädt dann ko-resident neben dem Hirn, statt es zu verdrängen."}
>
🧠
</button>
)}
{expert && (
<button
onClick={() => setSpecModel(m)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-all cursor-pointer flex items-center gap-1",
m.spec_active ? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
: m.spec_draft_model ? "border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
: "border-border/40 text-muted-foreground hover:bg-accent"
)}
title="Speculative Draft konfigurieren (Vocab-geprüft)"
>
<Zap className="h-3 w-3" /> Spec
</button>
)}
<button
onClick={() => handleDelete(m.name)}
disabled={isProtected(m.role)}
className={cn(
"h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 transition-all",
isProtected(m.role)
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:text-red-400 hover:bg-red-500/5 cursor-pointer"
)}
aria-label="Modell löschen"
title={isProtected(m.role) ? `${roleMeta(m.role).label}" ist geschützt und kann nicht gelöscht werden.` : "Modell löschen"}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
</div>
)
})
)}
</div>
)}
</div>
{/* Role Assignment Modal */}
{activeRoleForAssign && (
<RoleAssignModal role={activeRoleForAssign} roleRec={roleRec} models={models}
onAssign={(name) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }}
onClose={() => setActiveRoleForAssign(null)} />
)}
{specModel && (
<SpecDraftModal
model={specModel}
onClose={() => setSpecModel(null)}
onChanged={reload}
/>
)}
{dialogElement}
</div>
)
}