Single Source of Truth: Frontend/Backend-Ungereimtheiten ausgeraeumt (Review 15.07.)

Backend (Wahrheit reparieren):
- gateway_reachable() prueft jetzt den ECHTEN mc2-gateway (:9010 via V1_UPSTREAM)
  statt nur die Engine - toter Denkpfad war in Health/Cockpit unsichtbar
- /api/system/services vollstaendig: + LLM-Gateway, + Steuerpult, + Waechter
  (mc2-steward via is-active); scope-Feld (user/system); mc2-gateway/mc2-steward
  in die Restart-Allowlist
- Connect-Health Leitung 1 prueft den Client-Pfad (V1_UPSTREAM statt llama-swap)
- agent_status liefert pc_executor_url (UI zeigte hartcodierte IP/Ports)
- SSE-Endlosschleife gefixt: /api/auftragsbuch schrieb announce-branches bei JEDEM
  Aufruf neu -> mtime-Bump -> invalidate -> Refetch im 3-s-Takt je Client;
  jetzt nur noch bei echter Aenderung

Frontend (ein Datenweg):
- SystemDrawer komplett auf React-Query-Hooks (vorher eigenes 3-s-setInterval auf
  dieselben Endpunkte inkl. teurem Updates-Check); Dienste-Liste = Backend-Antwort
  (SERVICES-UI-Kopie geloescht); useDialog statt Eigenbau; fmtBytes statt Duplikat
- useLucyHealth: Dienst-Matching per systemd-unit statt Namensfragment; Gateway-
  Reparatur zielt auf mc2-gateway; neuer Waechter-Check; Backend-tot => ehrliches
  rotes Verdikt statt eingefrorener letzter Stand (auch Sidebar)
- Toter Code raus: views/models/Cockpit.tsx (908 Z.) + RoleAssignModal + Placeholder;
  LaneEditor (Routing-Policy) + WarmSetManager damit ZURUECK im UI als Tab
  "Routing & Warm-Set" im Modell-Manager
- Cockpit: blockierte Ideen-Karten erscheinen in "Braucht dich" + Kachel-Hint;
  Verbinden-Kachel zeigt 3 Leitungen live statt hartem "Zed"
- Maschinenraum: ehrliches "frei" (reale Belegung) + eigenes KV-Cache-Segment
- AgentView: Ports/PC-Adresse aus der API; Guide lehrt Lanes chat/coding
- queries.ts: useModels nutzt SSE-relax; Chronik-Limit im Query-Key;
  useJobs/useZeitmaschine mit enabled-Schalter

Verifiziert: tsc+vite gruen, Backend-Smoke beide Gateway-Modi, UI live gegen die
Box (Cockpit/Werkbank/Routing-Tab/Drawer rendern mit Echtdaten).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-15 22:22:27 +02:00
parent b36561990e
commit 0c69c491e5
161 changed files with 1522 additions and 2458 deletions
+11 -4
View File
@@ -18,6 +18,13 @@ export function AgentView() {
const installed = modelsData?.models ?? []
// Adressen aus dem Status-Payload statt hartcodiert — die UI zeigt, was konfiguriert IST.
const port = (url?: string) => url?.match(/:(\d+)/)?.[1] ?? ""
const gwPort = port(s?.gateway_url) || "8642"
const pcUrl = s?.pc_executor_url ?? ""
const pcHost = pcUrl.replace(/^https?:\/\//, "")
const pcPort = port(pcUrl) || "7777"
// Alias setzen → roher Config-Write (model.default zeigt aufs Alias).
async function setBrainAlias(alias: string) {
try {
@@ -52,7 +59,7 @@ export function AgentView() {
Hermes Agenten-Cockpit
</h1>
<p className="text-sm text-muted-foreground">
Hermes ist ein autonomer Agent, der im Hintergrund der Box läuft (Port <code>:8642</code>) und seine eigene UI besitzt.
Hermes ist ein autonomer Agent, der im Hintergrund der Box läuft (Port <code>:{gwPort}</code>) und seine eigene UI besitzt.
</p>
</div>
@@ -112,7 +119,7 @@ export function AgentView() {
<div className="rounded-xl border border-primary/40 bg-primary/5 px-3.5 py-2.5 flex items-center gap-2">
<Bot className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-semibold text-primary">Agent Gateway</span>
<span className="text-[11px] font-mono text-muted-foreground">:8642</span>
<span className="text-[11px] font-mono text-muted-foreground">:{gwPort}</span>
<span className={cn(
"ml-auto inline-flex items-center gap-1.5 text-[10px] font-bold uppercase font-mono px-2 py-0.5 rounded-full border",
s.gateway_reachable
@@ -159,7 +166,7 @@ export function AgentView() {
<span className={cn("ml-auto h-1.5 w-1.5 rounded-full", s.pc_executor_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
</div>
<div className={cn("text-[11px] font-mono mt-1", s.pc_executor_reachable ? "text-emerald-400" : "text-muted-foreground")}>
:7777 {s.pc_executor_reachable ? "verbunden" : "offline"}
:{pcPort} {s.pc_executor_reachable ? "verbunden" : "offline"}
</div>
</div>
</div>
@@ -185,7 +192,7 @@ export function AgentView() {
<div className="grid gap-6 md:grid-cols-2 text-xs text-muted-foreground leading-relaxed text-left">
<div className="space-y-3">
<p>
Der <strong className="text-foreground">Hermes PC Executor</strong> läuft auf <code className="text-foreground font-mono px-1 py-0.5 rounded bg-muted/40 border border-border/30">192.168.178.98:7777</code> und ermöglicht Hermes den direkten Zugriff auf deinen Windows-PC Shell-Befehle, Screenshots, Tastatureingaben.
Der <strong className="text-foreground">Hermes PC Executor</strong> läuft auf <code className="text-foreground font-mono px-1 py-0.5 rounded bg-muted/40 border border-border/30">{pcHost || "deinem Windows-PC"}</code> und ermöglicht Hermes den direkten Zugriff auf deinen Windows-PC Shell-Befehle, Screenshots, Tastatureingaben.
</p>
<p>
Hermes erreicht den PC über den MCP-Server <code className="text-foreground font-mono px-1 py-0.5 rounded bg-muted/40 border border-border/30">hermes-pc-control</code>, der automatisch beim Gateway-Start verbunden wird.
+4 -4
View File
@@ -281,11 +281,11 @@ export function GuideView() {
und transparentes <strong>Routing</strong> der Client merkt nicht, welches Modell tatsächlich antwortet.
</p>
<p>
Die <Pill>model:auto</Pill>-Idee: du sagst auto", das Gateway wählt das passende Modell je nach Anfrage und
lädt es bei Bedarf.
Die Lane-Idee: du wählst <Pill>chat</Pill> (Alltag, <Pill>auto</Pill> geht als Alias weiter) oder{" "}
<Pill>coding</Pill> (agentisch), das Gateway pickt das passende echte Modell und lädt es bei Bedarf.
</p>
<Stack>
Dein Gateway: <Pill>http://192.168.178.151:9001/v1</Pill>, Model <Pill>auto</Pill>, API-Key beliebig.
Dein Gateway: <Pill>http://192.168.178.151:9001/v1</Pill>, Model <Pill>chat</Pill> oder <Pill>coding</Pill>, API-Key beliebig.
Fertige Configs erzeugt dir der <strong>Verbinden</strong>-Tab live eine Wahrheit, kein Abtippen.
</Stack>
</Card>
@@ -398,7 +398,7 @@ description: Treibt Entwicklung mit strikter TDD-Praxis
Deine Haupt-IDE ist inzwischen <strong>Hermes Desktop</strong> — sie ist ab Werk mit Box, Gedächtnis und
Skills verdrahtet, da ist nichts zu konfigurieren. Für alles andere gilt: Tipp den Kram nicht ab — der
<strong> Verbinden</strong>-Tab generiert die fertige Config (inkl. MCP-Gedächtnis) pro Tool zum Kopieren.
Essenz: Base <Pill>:9001/v1</Pill>, Model <Pill>auto</Pill>, Key beliebig.
Essenz: Base <Pill>…:9001/v1</Pill>, Model <Pill>chat</Pill> / <Pill>coding</Pill>, Key beliebig.
</Stack>
</Card>
+64 -46
View File
@@ -1,46 +1,64 @@
import { useState } from "react"
import { cn } from "@/lib/utils"
import { JobsBar } from "@/components/models/JobsBar"
import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench"
import { Discover } from "./models/Discover"
export function ModelsView() {
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">
<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">
Modell-Manager
</h1>
<p className="text-sm text-muted-foreground">
Verwalte deine Modelle und passe das Gateway-Routing live über das interaktive Cockpit an.
</p>
</div>
<div className="flex mc-card-sm p-1 self-start">
{(["cockpit", "discover"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
tab === t
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "cockpit" ? "Werkbank" : "Modelle finden"}
</button>
))}
</div>
</div>
<JobsBar />
<div className="transition-all duration-300">
{tab === "cockpit" ? <ModelsWorkbench /> : <Discover />}
</div>
</div>
)
}
import { useState } from "react"
import { cn } from "@/lib/utils"
import { JobsBar } from "@/components/models/JobsBar"
import { LaneEditor } from "@/components/models/LaneEditor"
import { WarmSetManager } from "@/components/models/WarmSetManager"
import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench"
import { Discover } from "./models/Discover"
// Modell-Manager: Werkbank (verwalten) · Routing & Warm-Set (Lanes-Policy + Immer-bereit-Set,
// vorher im toten Alt-Cockpit verwaist und damit UNERREICHBAR — Review 15.07.) · Entdecken.
const TABS = [
{ id: "werkbank", label: "Werkbank" },
{ id: "routing", label: "Routing & Warm-Set" },
{ id: "discover", label: "Modelle finden" },
] as const
type TabId = (typeof TABS)[number]["id"]
export function ModelsView() {
const [tab, setTab] = useState<TabId>("werkbank")
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<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">
Modell-Manager
</h1>
<p className="text-sm text-muted-foreground">
Modelle verwalten, das Lane-Routing einstellen und Neues von HuggingFace holen.
</p>
</div>
<div className="flex mc-card-sm p-1 self-start">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
tab === t.id
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
: "text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
</div>
</div>
<JobsBar />
<div className="transition-all duration-300">
{tab === "werkbank" && <ModelsWorkbench />}
{tab === "routing" && (
<div className="space-y-5">
<LaneEditor />
<WarmSetManager />
</div>
)}
{tab === "discover" && <Discover />}
</div>
</div>
)
}
-16
View File
@@ -1,16 +0,0 @@
import { Construction } from "lucide-react"
export function Placeholder({ title, hint }: { title: string; hint: string }) {
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">{title}</h1>
<p className="text-sm text-muted-foreground">{hint}</p>
</div>
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border bg-card/50 py-20 text-center">
<Construction className="h-8 w-8 text-muted-foreground" />
<div className="text-sm text-muted-foreground">Kommt in einer späteren Phase.</div>
</div>
</div>
)
}
+50 -13
View File
@@ -2,12 +2,12 @@ import { useState } from "react"
import {
Boxes, Brain, Server, ShieldAlert, Plug,
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
Inbox,
Inbox, HelpCircle,
} from "lucide-react"
import { api } from "@/lib/api"
import {
useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth,
useAuftragsbuch, useQueryClient, qk,
useAuftragsbuch, useIdeen, useQueryClient, qk,
} from "@/lib/queries"
import { useLucyHealth, type Repair } from "@/lib/useLucyHealth"
import { useDialog } from "@/lib/useDialog"
@@ -34,6 +34,7 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth()
const { data: auftraege } = useAuftragsbuch()
const { data: ideen } = useIdeen()
const health = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
@@ -71,8 +72,17 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
const pendingCount =
(updates ? (updates.os > 0 ? 1 : 0) + (updates.engine > 0 ? 1 : 0) + (updates.swap > 0 ? 1 : 0) + (updates.models > 0 ? 1 : 0) : 0) +
(updates?.components?.filter((c) => c.update === true).length ?? 0)
const connectOk = connect?.gateway.ok && connect?.memory.ok
const openAuftraege = auftraege?.available ? auftraege.open_count : 0
// Hängende Karten warten auf DEINE Antwort — bisher sah das Cockpit sie gar nicht
// („Nichts zu tun" trotz roter Karte im Auftragsbuch, Review 15.07.).
const blockedIdeen = ideen?.available ? ideen.items.filter((i) => i.status === "blocked").length : 0
// Verbinden: die drei Leitungen (Modell-Gateway · Gedächtnis · Desktop-Gateway).
const connectLines = connect ? [connect.gateway, connect.memory, connect.desktop_gateway] : []
const connectOkCount = connectLines.filter((l) => l?.ok).length
const connectBad = connect
? (!connect.gateway.ok ? "Modell-Leitung" : !connect.memory.ok ? "Gedächtnis-Leitung" : !connect.desktop_gateway.ok ? "Desktop-Gateway" : "")
: ""
// Zusätzliche reaktive Livedaten für die Kacheln
const activeBrain = models?.models?.find((m: any) => m.role === "hermes")
@@ -103,7 +113,7 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
{/* Hauptspalte Links */}
<div className="lg:col-span-2 space-y-6">
{/* Status-Zeile */}
<StatusLine verdict={health.verdict} memory={health.memory} warns={health.warns} />
<StatusLine verdict={health.verdict} memory={health.memory} warns={health.warns} problems={health.problems} />
{/* Morgenlage — das jüngste Nacht-Gutachten (verschwindet, wenn keines da ist) */}
<MorgenlageCard />
@@ -127,8 +137,10 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
<CockpitTile
icon={Inbox} title="Auftragsbuch" value={auftraege?.available ? openAuftraege : "—"}
unit={openAuftraege === 1 ? "Vorschlag" : "Vorschläge"}
tone={!auftraege ? "loading" : openAuftraege > 0 ? "warn" : "ok"}
hint={openAuftraege > 0 ? `${openAuftraege} offene Patches` : "Keine ausstehenden Vorschläge"}
tone={!auftraege ? "loading" : blockedIdeen > 0 || openAuftraege > 0 ? "warn" : "ok"}
hint={blockedIdeen > 0
? `${blockedIdeen} Karte${blockedIdeen === 1 ? "" : "n"} wartet auf deine Antwort`
: openAuftraege > 0 ? `${openAuftraege} offene Patches` : "Keine ausstehenden Vorschläge"}
onClick={() => onNavigate("auftraege")}
/>
<CockpitTile
@@ -157,9 +169,10 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
onClick={() => openDrawer("maintenance")}
/>
<CockpitTile
icon={Plug} title="Verbinden" value="Zed" unit={connectOk ? "· bereit" : ""}
tone={!connect ? "loading" : connectOk ? "ok" : "warn"}
hint={agent?.pc_executor_reachable ? "IDE & PC online (:7777)" : "IDE-Kopplung (PC offline)"}
icon={Plug} title="Verbinden" value={connect ? `${connectOkCount}/${connectLines.length}` : ""}
unit="Leitungen"
tone={!connect ? "loading" : connectOkCount === connectLines.length ? "ok" : "warn"}
hint={connectBad ? `Gestört: ${connectBad}` : "Hermes Desktop & IDEs bereit"}
onClick={() => onNavigate("connect")}
/>
</div>
@@ -173,6 +186,7 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
problems={health.problems}
pendingCount={pendingCount}
openAuftraege={openAuftraege}
blockedIdeen={blockedIdeen}
busy={busy}
onRepair={runRepair}
onNavigate={onNavigate}
@@ -202,19 +216,23 @@ function ZoneLabel({ children }: { children: React.ReactNode }) {
}
// Ehrliche Status-Zeile: großes Verdikt + Speicher-Pille. Erzählrahmen = Box.
function StatusLine({ verdict, memory, warns }: {
function StatusLine({ verdict, memory, warns, problems }: {
verdict: ReturnType<typeof useLucyHealth>["verdict"]
memory: ReturnType<typeof useLucyHealth>["memory"]
warns: ReturnType<typeof useLucyHealth>["warns"]
problems: ReturnType<typeof useLucyHealth>["problems"]
}) {
const warnText = warns.length
? `${warns[0].label}: ${warns[0].detail}${warns.length > 1 ? ` (+${warns.length - 1} weitere)` : ""}`
: memory.status === "full" ? memory.text : "Nichts Schlimmes — nur ein Hinweis."
const problemText = problems.length
? `${problems[0].label}: ${problems[0].detail}${problems.length > 1 ? ` (+${problems.length - 1} weitere)` : ""}`
: "Ein wichtiger Dienst hakt."
const b = {
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: "Box wird geprüft …", sub: "Einen Moment." },
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: "Box gesund", sub: "Alle wichtigen Dienste laufen." },
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: "Kleinigkeit an der Box", sub: warnText },
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: "Box braucht Hilfe", sub: "Ein wichtiger Dienst hakt." },
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: "Box braucht Hilfe", sub: problemText },
}[verdict]
const Icon = b.icon
const memTone = { ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground" }[memory.status]
@@ -253,18 +271,20 @@ function StatusLine({ verdict, memory, warns }: {
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates
// + offene Vorschläge im Auftragsbuch.
function NeedsYou({
problems, pendingCount, openAuftraege, busy, onRepair, onNavigate,
problems, pendingCount, openAuftraege, blockedIdeen, busy, onRepair, onNavigate,
}: {
problems: ReturnType<typeof useLucyHealth>["problems"]
pendingCount: number
openAuftraege: number
blockedIdeen: number
busy: Record<string, boolean>
onRepair: (id: string, r: Repair) => void
onNavigate: (v: string) => void
}) {
const hasUpdates = pendingCount > 0
const hasAuftraege = openAuftraege > 0
const nothing = problems.length === 0 && !hasUpdates && !hasAuftraege
const hasBlocked = blockedIdeen > 0
const nothing = problems.length === 0 && !hasUpdates && !hasAuftraege && !hasBlocked
return (
<div className="h-full flex flex-col justify-between">
@@ -301,6 +321,23 @@ function NeedsYou({
)}
</div>
))}
{hasBlocked && (
<button
onClick={() => onNavigate("auftraege")}
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-red-500/30 bg-red-500/5 p-4 text-left transition-all hover:bg-red-500/10 cursor-pointer"
>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-red-500/30 bg-background/30 text-red-300">
<HelpCircle className="h-4.5 w-4.5" />
</span>
<div>
<p className="text-sm font-bold text-foreground">{blockedIdeen} Karte{blockedIdeen === 1 ? "" : "n"} braucht deine Antwort</p>
<p className="text-xs text-muted-foreground">Die Box hängt an einer Frage und wartet auf dich.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-red-300/70" />
</button>
)}
{hasAuftraege && (
<button
onClick={() => onNavigate("auftraege")}
@@ -6,7 +6,9 @@ import { useChronik } from "@/lib/queries"
// Leerlauf über die autonome Arbeit; der Feed spiegelt es still in den Briefkasten).
// Keine Lage vorhanden → Karte verschwindet ganz (Ruhe statt leerer Fläche).
export function MorgenlageCard() {
const { data: items = [] } = useChronik(150, 60_000)
// Standard-Takt & -Limit: teilt sich den Cache-Eintrag mit der Chronik-View
// (eigene Intervalle sind laut queries.ts-Regel tabu — kleinster Takt gewinnt eh).
const { data: items = [] } = useChronik()
const [open, setOpen] = useState(false)
const lage = items.find((i) => i.source === "chef-gutachter")
if (!lage) return null
-908
View File
@@ -1,908 +0,0 @@
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()
const { data: updates } = useUpdates()
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="mc-card p-5 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="mc-card p-5 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(
"mc-card p-5 flex flex-col justify-between gap-4 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(
"mc-card-sm p-3.5 flex flex-col sm:flex-row sm:items-center justify-between gap-3",
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>
)
}
@@ -1,104 +0,0 @@
import { Check, X, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
import { fmtSize } from "@/lib/format"
import { roleMeta } from "@/lib/roleMeta"
import { RoleLabel } from "@/components/models/ModelBadges"
import type { ModelInfo, RoleRecResp } from "@/lib/api"
// Rollen-Zuweisungs-Modal des Cockpits (Review P2-14, Teil 2: aus dem 990-Z-Monolithen
// extrahiert). Zeigt die Modell-Bibliothek sortiert nach Empfehlung (RoleRec-Score) und
// hält das Schutzgeländer: lebenswichtige Rollen (Hirn/Gedächtnis) können nicht leer bleiben.
const isProtected = (role?: string | null) => !!roleMeta(role).protected
export function RoleAssignModal({ role, roleRec, models, onAssign, onClose }: {
role: string
roleRec: RoleRecResp | null
models: ModelInfo[]
onAssign: (modelName: string) => void
onClose: () => void
}) {
const rec = roleRec && roleRec.role === role ? roleRec : null
const recByName: Record<string, RoleRecResp["models"][number]> = {}
rec?.models.forEach((r) => { recByName[r.name] = r })
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
const ordered = rec
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
: models
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(role).label} konfigurieren`}>
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<RoleLabel role={role} /> festlegen
</h3>
<button
onClick={onClose}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Wähle ein Modell für <strong className="text-foreground">{roleMeta(role).label}</strong>
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(role).desc}</span>
</p>
{rec?.recommended && (
<button
onClick={() => onAssign(rec.recommended!)}
title={recByName[rec.recommended]?.reason}
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
>
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
</button>
)}
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
{isProtected(role) ? (
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben wähle stattdessen ein anderes Modell.
</div>
) : (
<button
onClick={() => onAssign("")}
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
>
<span>Zuweisung entfernen</span>
</button>
)}
{ordered.map((m) => {
const r = recByName[m.name]
const isCur = m.role === role
const isRec = !!r?.recommended
const unfit = !!r && !r.suitable
return (
<button
key={m.name}
onClick={() => onAssign(m.name)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
isRec ? "border-primary/50 bg-primary/10"
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
: unfit ? "border-border/20 bg-background/10 opacity-60"
: "text-foreground bg-background/20 border-border/30"
)}
>
<div className="flex flex-col text-left min-w-0">
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
</span>
</div>
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
)
})}
</div>
</div>
</div>
)
}
@@ -22,8 +22,14 @@ export function MaschinenraumBar({
}) {
const capacity = capacityBytes > 2 * B ? capacityBytes : 124 * B
const weights = running.reduce((a, m) => a + (m.size_bytes || 0), 0)
const freeBytes = Math.max(0, capacity - weights)
// Ehrliche Rechnung: „frei" geht von der REALEN Belegung aus (inkl. KV-Cache), nicht
// nur von den Dateigrößen — vorher konnte die Leiste viel „frei" zeigen, während der
// Speicher praktisch voll war (Review 15.07.). Die Differenz wird als eigenes
// Betriebs-Segment (KV-Cache/Overhead) sichtbar statt verschwiegen.
const overheadBytes = Math.max(0, realUsedBytes - weights)
const freeBytes = Math.max(0, capacity - Math.max(weights, realUsedBytes))
const freePct = (freeBytes / capacity) * 100
const overheadPct = (overheadBytes / capacity) * 100
return (
<div className="mc-card p-5">
@@ -31,13 +37,13 @@ export function MaschinenraumBar({
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Arbeitsspeicher</div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Modell-Speicher (GPU-Pool)</div>
<div className="font-space text-xl font-bold tracking-tight text-foreground">{gb(capacity)} GB <span className="text-sm font-medium text-muted-foreground">gesamt</span></div>
</div>
</div>
<div className="text-right text-[11px] font-mono text-muted-foreground">
<div><span className="font-bold text-foreground">{gb(weights)} GB</span> Modelle geladen</div>
{realUsedBytes > 0 && <div>{gb(realUsedBytes)} GB real belegt (inkl. KV-Cache)</div>}
<div><span className="font-bold text-foreground">{gb(weights)} GB</span> Modell-Gewichte</div>
{overheadBytes > 0 && <div>{gb(overheadBytes)} GB KV-Cache & Betrieb</div>}
<div className="text-emerald-400">{gb(freeBytes)} GB frei</div>
</div>
</div>
@@ -73,6 +79,15 @@ export function MaschinenraumBar({
</button>
)
})}
{overheadPct > 1.5 && (
<div
style={{ width: `${overheadPct}%` }}
className="flex h-full min-w-[2rem] items-center justify-center rounded-lg border border-dashed border-border/60 bg-background/40 text-[9px] font-mono text-muted-foreground/70"
title={`${gb(overheadBytes)} GB KV-Cache & Betrieb (reale Belegung über den Modell-Gewichten)`}
>
KV
</div>
)}
{freePct > 1.5 && (
<div
style={{ width: `${freePct}%` }}
@@ -103,6 +118,11 @@ export function MaschinenraumBar({
</button>
)
})}
{overheadBytes > 0 && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60" title="Reale Belegung über den Modell-Gewichten (Kontext-Speicher der laufenden Modelle)">
<span className="h-2.5 w-2.5 rounded-sm border border-dashed border-border/60 bg-background/40" /> KV-Cache & Betrieb · {gb(overheadBytes)} GB
</span>
)}
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60">
<span className="h-2.5 w-2.5 rounded-sm border border-border/50 bg-background/50" /> frei · {gb(freeBytes)} GB
</span>