UI v3: Cockpit-Startseite + Modelle-Werkbank als Default, neue Box-Konsole

Kompletter Frontend-Umbau auf das abgenommene v3-Konzept, dazu zwei neue Funktionen.

Frontend (Neuordnung bestehender IST-Views, kein Backend-Umbau):
- Cockpit ist die neue Startseite: ruhige Bereichs-Kacheln (je 1 Live-Zahl +
  Status-Punkt), ehrliche Status-Zeile "Box gesund" + Speicher-Pille, "Braucht dich"
  (kritische Probleme mit 1-Klick-Reparatur + bereitliegende Updates).
- Modelle-Werkbank: Maschinenraum-Speicherleiste als Hero (Arbeitsspeicher-Balken,
  nach Rolle eingefaerbt + frei), darunter Master/Detail. Eingebettet als Haupt-Tab
  im Modell-Manager ("Werkbank"), "Modelle finden" + JobsBar bleiben.
- Sidebar/Nav neu strukturiert; alle Aktionen ueber die bestehenden /api-Endpoints.

Neue Features:
- Box-Konsole: zweites ttyd-Web-Terminal mit echter Login-Shell auf :7682 (direkter,
  SSH-artiger Box-Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie hermes-terminal).
  Neuer Dienst deploy/box-console.service + agent_status-Felder box_console_url/-reachable.
- Box-Zugang: das Host-Sudo-Passwort laesst sich jetzt direkt in der Konsole-Seite
  setzen/aendern/loeschen (lokal im Browser), statt nur versteckt im System-Drawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-04 20:41:19 +02:00
parent 854393ce5f
commit 18afa37412
20 changed files with 1692 additions and 679 deletions
+5 -3
View File
@@ -3,12 +3,12 @@ import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ExpertToggle } from "@/components/ExpertToggle"
import { DashboardView } from "@/views/DashboardView"
import { ModelsView } from "@/views/ModelsView"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { AgentView } from "@/views/AgentView"
import { TerminalView } from "@/views/TerminalView"
import { KonsoleView } from "@/views/KonsoleView"
import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer"
@@ -16,6 +16,7 @@ import { useHealth, useSystemStatus } from "@/lib/queries"
import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils"
import { Mc3App } from "@/mc3/Mc3App"
import { CockpitView } from "@/views/cockpit/CockpitView"
export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
@@ -228,14 +229,15 @@ export default function App() {
</header>
<main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
{view === "dashboard" && <DashboardView />}
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />}
{view === "agent" && <AgentView />}
{view === "terminal" && <TerminalView />}
{view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />}
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "guide"].includes(view) && (
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "konsole", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
+2
View File
@@ -372,8 +372,10 @@ export interface DedupeResult {
export interface AgentStatus {
gateway_url: string
terminal_url: string
box_console_url?: string
gateway_reachable: boolean
terminal_reachable: boolean
box_console_reachable?: boolean
home_exists: boolean
brain_model?: string
has_config: boolean
+6 -4
View File
@@ -5,11 +5,12 @@ import {
Plug,
Bot,
TerminalSquare,
SquareTerminal,
HelpCircle,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "guide"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "konsole" | "guide"
export interface NavItem {
id: ViewId
@@ -20,12 +21,13 @@ export interface NavItem {
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", label: "Zentrale", hint: "System- & Stack-Status", icon: LayoutDashboard },
{ id: "models", label: "Modell-Manager", hint: "Verwalten, laden & Gateway-Routing", icon: Boxes },
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard },
{ id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot },
{ id: "terminal", label: "Terminal", hint: "Interaktives Hermes-Agent-Terminal", icon: TerminalSquare },
{ id: "terminal", label: "Hermes-Terminal", hint: "Interaktives Hermes-Agent-Terminal", icon: TerminalSquare },
{ id: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal },
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle },
]
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react"
import { SquareTerminal, ExternalLink, AlertTriangle, Shield, Eye, EyeOff, Check, Trash2 } from "lucide-react"
import { useAgentStatus } from "@/lib/queries"
import { resolveExternalUrl, cn } from "@/lib/utils"
// Konsole = direkter, SSH-artiger Box-Zugriff (ttyd → Login-Shell auf :7682), eingebettet
// per iframe — Schwester der Hermes-Terminal-Seite. Darunter „Box-Zugang": das Host-Sudo-
// Passwort, das MC2 für System-Operationen (Neustarts, apt, Reboot) braucht, hier direkt
// änder-/löschbar (nur lokal im Browser gespeichert, wie in den Einstellungen).
export function KonsoleView() {
const { data: agent } = useAgentStatus(5_000)
const url = agent?.box_console_url ? resolveExternalUrl(agent.box_console_url) : undefined
const ok = agent?.box_console_reachable
return (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Konsole
</h1>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
Direkte Shell auf der Box wie ein SSH-Fenster, mitten im Browser.
</p>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
<span className={cn("h-2 w-2 rounded-full", ok ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
{ok ? "online" : "offline"}
</span>
{url && (
<a href={url} target="_blank" rel="noopener"
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground">
<ExternalLink className="h-3.5 w-3.5" /> In neuem Tab
</a>
)}
</div>
</div>
{url ? (
<div className="relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25">
{ok === false && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center">
<AlertTriangle className="h-8 w-8 text-amber-400" />
<div className="text-sm font-semibold text-amber-300">Konsole nicht erreichbar</div>
<p className="max-w-sm text-[11px] leading-normal text-muted-foreground">
Der <code className="font-mono text-primary">box-console</code>-Dienst (ttyd) läuft nicht.
Auf der Box: <code className="font-mono">systemctl --user restart box-console</code>.
</p>
</div>
)}
<iframe src={url} title="Box-Konsole" className="h-full w-full border-0" style={{ minHeight: "58vh" }} />
</div>
) : (
<div className="flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground">
<SquareTerminal className="mr-2 h-4 w-4" /> Lade Konsole
</div>
)}
<BoxAccessCard />
</div>
)
}
// „Box-Zugang" — das Host-Sudo-Passwort direkt setzen/ändern/löschen. Reine Frontend-
// Verwaltung des lokal gespeicherten Werts (localStorage `mc_sudo_password`), den api.ts
// bei Bedarf mitschickt. KEINE Änderung des echten Linux-Kontopassworts.
function BoxAccessCard() {
const [pw, setPw] = useState("")
const [stored, setStored] = useState(false)
const [show, setShow] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
const v = localStorage.getItem("mc_sudo_password") || ""
setPw(v); setStored(!!v)
}, [])
function save() {
if (pw) localStorage.setItem("mc_sudo_password", pw)
else localStorage.removeItem("mc_sudo_password")
setStored(!!pw)
setSaved(true); setTimeout(() => setSaved(false), 1800)
}
function clear() {
localStorage.removeItem("mc_sudo_password"); setPw(""); setStored(false)
setSaved(true); setTimeout(() => setSaved(false), 1800)
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mb-1 flex items-center gap-2">
<Shield className="h-4.5 w-4.5 text-amber-400" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Box-Zugang</h2>
<span className={cn("ml-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold",
stored ? "border-emerald-500/30 bg-emerald-500/5 text-emerald-400" : "border-border/40 bg-background/30 text-muted-foreground")}>
{stored ? "gesetzt" : "nicht gesetzt"}
</span>
</div>
<p className="mb-3 text-[11px] leading-normal text-muted-foreground">
Das Box-Passwort (Sudo) braucht MC2 für System-Aktionen wie Dienst-Neustarts, OS-Updates und Neustart der Box.
Es wird nur lokal in deinem Browser gespeichert. Hier kannst du es setzen, ändern oder löschen.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative flex-1">
<input
type={show ? "text" : "password"} value={pw} onChange={(e) => setPw(e.target.value)}
placeholder="Box-Passwort" autoComplete="off" spellCheck={false} name="box-password"
className="h-10 w-full rounded-lg border border-border/60 bg-background/40 pl-3 pr-10 text-xs text-foreground outline-none transition-colors focus:border-primary"
/>
<button type="button" onClick={() => setShow(!show)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground transition-colors hover:text-foreground"
aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<button onClick={save}
className="flex h-10 items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-xs font-semibold text-primary-foreground shadow shadow-primary/10 transition-colors hover:bg-primary/90 cursor-pointer">
{saved ? <><Check className="h-4 w-4" /> gespeichert</> : "Speichern"}
</button>
<button onClick={clear} disabled={!stored && !pw}
className="flex h-10 items-center justify-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent cursor-pointer disabled:opacity-40">
<Trash2 className="h-3.5 w-3.5" /> Löschen
</button>
</div>
</div>
)
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { useState } from "react"
import { cn } from "@/lib/utils"
import { JobsBar } from "@/components/models/JobsBar"
import { Cockpit } from "./models/Cockpit"
import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench"
import { Discover } from "./models/Discover"
export function ModelsView() {
@@ -30,7 +30,7 @@ export function ModelsView() {
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "cockpit" ? "Cockpit" : "Modelle finden"}
{t === "cockpit" ? "Werkbank" : "Modelle finden"}
</button>
))}
</div>
@@ -39,7 +39,7 @@ export function ModelsView() {
<JobsBar />
<div className="transition-all duration-300">
{tab === "cockpit" ? <Cockpit /> : <Discover />}
{tab === "cockpit" ? <ModelsWorkbench /> : <Discover />}
</div>
</div>
)
@@ -0,0 +1,68 @@
import { ChevronRight, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
export type TileTone = "ok" | "warn" | "alert" | "muted" | "loading"
const DOT: Record<TileTone, string> = {
ok: "bg-emerald-500",
warn: "bg-amber-500",
alert: "bg-red-500",
muted: "bg-muted-foreground/40",
loading: "bg-muted-foreground/40 animate-pulse",
}
const RING: Record<TileTone, string> = {
ok: "hover:border-emerald-500/40",
warn: "hover:border-amber-500/50",
alert: "hover:border-red-500/50",
muted: "hover:border-primary/40",
loading: "hover:border-border/60",
}
// Eine Bereichs-Kachel der Cockpit-Startseite: EIN Symbol, EINE große Live-Zahl,
// EIN Status-Punkt, Klartext-Titel. Tiefe steckt hinter dem Klick (Werkbank/Drawer).
export function CockpitTile({
icon: Icon,
title,
value,
unit,
tone = "muted",
hint,
onClick,
}: {
icon: LucideIcon
title: string
value: React.ReactNode
unit?: string
tone?: TileTone
hint?: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"group relative flex flex-col items-start rounded-2xl border border-border/60 bg-card/45 p-5 text-left shadow-lg shadow-black/15 backdrop-blur-md transition-all duration-200 cursor-pointer hover:bg-card/70 hover:shadow-xl",
RING[tone],
)}
>
<div className="mb-4 flex w-full items-center justify-between">
<span className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/40 bg-background/30 text-foreground/80 transition-colors group-hover:text-primary">
<Icon className="h-5 w-5" />
</span>
<span className={cn("h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[tone])} title={hint} />
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl font-bold tracking-tight text-foreground font-space tabular-nums">{value}</span>
{unit && <span className="text-xs font-medium text-muted-foreground">{unit}</span>}
</div>
<div className="mt-1 flex w-full items-center justify-between">
<span className="text-sm font-semibold text-foreground/90">{title}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 transition-all group-hover:translate-x-0.5 group-hover:text-primary" />
</div>
{hint && <span className="mt-0.5 text-[11px] text-muted-foreground">{hint}</span>}
</button>
)
}
+272
View File
@@ -0,0 +1,272 @@
import { useState } from "react"
import {
Boxes, Brain, Server, ShieldAlert, Plug, Bot, TerminalSquare, ScrollText,
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
} from "lucide-react"
import { api } from "@/lib/api"
import {
useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth,
useQueryClient, qk,
} from "@/lib/queries"
import { SquareTerminal } from "lucide-react"
import { useLucyHealth, type Repair } from "@/lib/useLucyHealth"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { CockpitTile } from "./CockpitTile"
// Öffnet die bestehende System-Schublade (Pflege/Logs) via globalem Event.
const openDrawer = (tab: "maintenance" | "logs") =>
window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab } }))
// Cockpit = die Startseite. Ruhige Übersicht: ehrliche Status-Zeile oben, „Braucht
// dich"-Bereich für offene Aktionen, darunter die Bereichs-Kacheln (je EINE Live-Zahl).
// Überblick außen, Tiefe hinter der Kachel — Daten/Aktionen aus den IST-Hooks.
export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void }) {
const { data: models } = useModels(4_000)
const { data: svc } = useServices(4_000)
const { data: updates } = useUpdates(8_000)
const { data: memory } = useMemory()
const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth()
const health = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const [busy, setBusy] = useState<Record<string, boolean>>({})
async function runRepair(id: string, repair: Repair) {
setBusy((b) => ({ ...b, [id]: true }))
try {
if (repair.kind === "loadModel") {
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
} else {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service: repair.service }),
})
if (!r.ok) showAlert("Reparatur nicht ganz geklappt", (r.err || "Unbekannter Fehler") +
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""))
}
qc.invalidateQueries({ queryKey: qk.health })
qc.invalidateQueries({ queryKey: qk.services })
qc.invalidateQueries({ queryKey: qk.models })
} catch (e: any) {
showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [id]: false }))
}
}
// ── Kachel-Zahlen aus den IST-Daten ableiten ──────────────────────────────
const running = models?.running?.length ?? 0
const svcOk = svc?.services.filter((s) => s.ok).length ?? 0
const svcTotal = svc?.services.length ?? 0
const svcErrors = svcTotal - svcOk
const notes = memory?.length ?? 0
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
return (
<div className="space-y-7">
{/* Kopf */}
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Cockpit
</h1>
<p className="text-sm text-muted-foreground">Deine Box auf einen Blick Details hinter jeder Kachel.</p>
</div>
{/* Ehrliche Status-Zeile */}
<StatusLine verdict={health.verdict} memory={health.memory} />
{/* „Braucht dich" — offene Aktionen, sonst Ruhe */}
<NeedsYou
problems={health.problems}
pendingCount={pendingCount}
busy={busy}
onRepair={runRepair}
/>
{/* Bereichs-Kacheln */}
<section>
<ZoneLabel>Bereiche</ZoneLabel>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<CockpitTile
icon={Boxes} title="Modelle" value={running} unit="warm"
tone={running > 0 ? "ok" : "muted"} hint="Speicher, laden & Rollen"
onClick={() => onNavigate("models")}
/>
<CockpitTile
icon={Brain} title="Gedächtnis" value={memory ? notes : "…"} unit="Notizen"
tone={memory ? "ok" : "loading"} hint="Lucys geteiltes Wissen"
onClick={() => onNavigate("memory")}
/>
<CockpitTile
icon={Server} title="Dienste" value={svc ? `${svcOk}/${svcTotal}` : "…"} unit="laufen"
tone={!svc ? "loading" : svcErrors > 0 ? "warn" : "ok"} hint="Status & Neustart"
onClick={() => openDrawer("maintenance")}
/>
<CockpitTile
icon={ShieldAlert} title="Updates" value={updates ? (pendingCount > 0 ? pendingCount : "0") : "…"}
unit={pendingCount > 0 ? "bereit" : "aktuell"}
tone={!updates ? "loading" : pendingCount > 0 ? "warn" : "ok"} hint="Prüfen & einspielen"
onClick={() => openDrawer("maintenance")}
/>
<CockpitTile
icon={Plug} title="Verbinden" value="Zed" unit={connectOk ? "· bereit" : ""}
tone={!connect ? "loading" : connectOk ? "ok" : "warn"} hint="IDE- & Agent-Configs"
onClick={() => onNavigate("connect")}
/>
<CockpitTile
icon={ScrollText} title="Logs" value={svc ? svcErrors : "…"} unit={svcErrors === 1 ? "Fehler" : "Fehler"}
tone={!svc ? "loading" : svcErrors > 0 ? "alert" : "ok"} hint="Verlauf & Diagnose"
onClick={() => openDrawer("logs")}
/>
</div>
</section>
{/* Sekundäre Bereiche */}
<section>
<ZoneLabel>Mehr</ZoneLabel>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<CockpitTile
icon={Bot} title="Hermes" value={agent ? (agent.gateway_reachable ? "bereit" : "offline") : "…"}
tone={!agent ? "loading" : agent.gateway_reachable ? "ok" : "alert"} hint="Agent-Status & Verdrahtung"
onClick={() => onNavigate("agent")}
/>
<CockpitTile
icon={TerminalSquare} title="Terminal" value="öffnen"
tone="muted" hint="Interaktives Agent-Terminal"
onClick={() => onNavigate("terminal")}
/>
<CockpitTile
icon={SquareTerminal} title="Konsole" value={agent ? (agent.box_console_reachable ? "bereit" : "offline") : "…"}
tone={!agent ? "loading" : agent.box_console_reachable ? "ok" : "warn"} hint="Direkte Box-Shell (SSH)"
onClick={() => onNavigate("konsole")}
/>
</div>
</section>
{dialogElement}
</div>
)
}
function ZoneLabel({ children }: { children: React.ReactNode }) {
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
}
// Ehrliche Status-Zeile: großes Verdikt + Speicher-Pille. Erzählrahmen = Box.
function StatusLine({ verdict, memory }: { verdict: ReturnType<typeof useLucyHealth>["verdict"]; memory: ReturnType<typeof useLucyHealth>["memory"] }) {
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: "Nichts Schlimmes — nur ein Hinweis." },
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." },
}[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]
const memBar = { ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40" }[memory.status]
return (
<div className={cn("flex flex-col gap-4 rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors sm:flex-row sm:items-center sm:justify-between", b.ring)}>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border/40 bg-background/30">
<Icon className={cn("h-6 w-6", b.iconCls)} />
</div>
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight text-foreground">{b.title}</h2>
<p className="text-xs text-muted-foreground">{b.sub}</p>
</div>
</div>
{/* Speicher-Pille */}
<div className="w-full max-w-[16rem] rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" /> Speicher
</span>
<span className={cn("font-mono font-bold", memTone)}>
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.totalGb.toFixed(0)} GB`}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", memBar)} style={{ width: `${memory.pct}%` }} />
</div>
</div>
</div>
)
}
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates.
// Nichts offen → ruhige Bestätigung statt leerer Fläche.
function NeedsYou({
problems, pendingCount, busy, onRepair,
}: {
problems: ReturnType<typeof useLucyHealth>["problems"]
pendingCount: number
busy: Record<string, boolean>
onRepair: (id: string, r: Repair) => void
}) {
const hasUpdates = pendingCount > 0
const nothing = problems.length === 0 && !hasUpdates
return (
<section>
<ZoneLabel>Braucht dich</ZoneLabel>
{nothing ? (
<div className="flex items-center gap-3 rounded-2xl border border-emerald-500/25 bg-emerald-500/5 p-4">
<Check className="h-5 w-5 shrink-0 text-emerald-400" />
<div>
<p className="text-sm font-semibold text-foreground">Nichts zu tun</p>
<p className="text-xs text-muted-foreground">Alles läuft. Du musst gerade nichts abnicken.</p>
</div>
</div>
) : (
<div className="space-y-2">
{problems.map((c) => (
<div key={c.id} className="flex items-center justify-between gap-3 rounded-2xl border border-red-500/25 bg-red-500/5 p-4">
<div className="flex min-w-0 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">
<c.icon className="h-4.5 w-4.5" />
</span>
<div className="min-w-0">
<p className="text-sm font-bold text-foreground">{c.label}</p>
<p className="truncate text-xs text-muted-foreground">{c.detail}</p>
</div>
</div>
{c.repair && (
<button
onClick={() => c.repair && onRepair(c.id, c.repair)}
disabled={!!busy[c.id]}
className="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border border-red-500/40 bg-red-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300 transition-all hover:bg-red-500/20 cursor-pointer disabled:opacity-60"
>
{busy[c.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
{c.repair.label}
</button>
)}
</div>
))}
{hasUpdates && (
<button
onClick={() => openDrawer("maintenance")}
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-left transition-all hover:bg-amber-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-amber-500/30 bg-background/30 text-amber-300">
<ShieldAlert className="h-4.5 w-4.5" />
</span>
<div>
<p className="text-sm font-bold text-foreground">{pendingCount} Update{pendingCount === 1 ? "" : "s"} bereit</p>
<p className="text-xs text-muted-foreground">Ansehen und entscheiden, ob du es einspielst.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-amber-300/70" />
</button>
)}
</div>
)}
</section>
)
}
@@ -0,0 +1,113 @@
import { HardDrive } from "lucide-react"
import type { ModelInfo } from "@/lib/api"
import { fmtSize, gb } from "@/lib/format"
import { roleMeta } from "@/lib/roleMeta"
import { cn } from "@/lib/utils"
import { roleBarColor } from "./roleColors"
const B = 1024 ** 3
const short = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") ?? name
// Maschinenraum-Speicherleiste — das Signatur-Element. EIN großer Balken „Arbeitsspeicher
// · 124 GB", farbig aufgeteilt nach geladenem Modell (nach Rolle eingefärbt) + freier Rest.
// Man sieht LIVE, wohin die GB gehen. Ein Klick auf ein Segment wählt das Modell rechts.
export function MaschinenraumBar({
running, capacityBytes, realUsedBytes, selected, onSelect,
}: {
running: ModelInfo[]
capacityBytes: number
realUsedBytes: number
selected?: string | null
onSelect?: (name: string) => void
}) {
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)
const freePct = (freeBytes / capacity) * 100
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
<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="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 className="text-emerald-400">{gb(freeBytes)} GB frei</div>
</div>
</div>
{/* Der Balken */}
<div className="flex h-12 w-full overflow-hidden rounded-xl border border-border/40 bg-background/50 p-1">
{running.length === 0 ? (
<div className="flex w-full items-center justify-center text-[11px] italic text-muted-foreground/60">
Kein Modell geladen Auto-Swap holt bei Anfrage automatisch das passende.
</div>
) : (
<>
{running.map((m) => {
const pct = ((m.size_bytes || 0) / capacity) * 100
const c = roleBarColor(m.role)
const isSel = selected === m.name
return (
<button
key={m.name}
onClick={() => onSelect?.(m.name)}
style={{ width: `${pct}%` }}
title={`${roleMeta(m.role).label} · ${short(m.name)} · ${fmtSize(m.size_bytes)}`}
className={cn(
"group flex h-full min-w-[2.5rem] shrink-0 flex-col justify-center gap-0.5 rounded-lg px-2 text-left text-white transition-all",
c.bar,
isSel ? "ring-2 ring-white/70" : "opacity-90 hover:opacity-100",
)}
>
<span className="truncate font-space text-[10px] font-bold leading-tight tracking-wide">
{roleMeta(m.role).short}
</span>
<span className="truncate font-mono text-[9px] leading-tight opacity-85">{fmtSize(m.size_bytes)}</span>
</button>
)
})}
{freePct > 1.5 && (
<div
style={{ width: `${freePct}%` }}
className="flex h-full min-w-[2rem] items-center justify-center rounded-lg text-[9px] font-mono text-muted-foreground/50"
title={`${gb(freeBytes)} GB frei`}
>
frei
</div>
)}
</>
)}
</div>
{/* Legende */}
{running.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{running.map((m) => {
const c = roleBarColor(m.role)
return (
<button
key={m.name}
onClick={() => onSelect?.(m.name)}
className="flex items-center gap-1.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
>
<span className={cn("h-2.5 w-2.5 rounded-sm", c.dot)} />
<span className={cn("font-semibold", c.text)}>{roleMeta(m.role).short}</span>
<span className="font-mono text-muted-foreground/70">{short(m.name)}</span>
</button>
)
})}
<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>
</div>
)}
</div>
)
}
@@ -0,0 +1,357 @@
import { useMemo, useState } from "react"
import {
Search, Power, PowerOff, Edit3, Trash2, Zap, HardDrive, Cpu, Lock, Brain, Check,
} from "lucide-react"
import { api, type ModelInfo } from "@/lib/api"
import { useModels, useSystemStatus, useGroups, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { useExpertMode } from "@/lib/useExpertMode"
import { CapsChips } from "@/components/CapsChips"
import { RoleLabel, getBrandInfo } from "@/components/models/ModelBadges"
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
import { ROLE_META, roleMeta } from "@/lib/roleMeta"
import { fmtSize, fmtCtx } from "@/lib/format"
import { cn } from "@/lib/utils"
import { MaschinenraumBar } from "./MaschinenraumBar"
import { roleBarColor } from "./roleColors"
const B = 1024 ** 3
const short = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") ?? name
// Modelle-Werkbank (Frontend v3): Maschinenraum-Speicherleiste als Hero oben, darunter
// Master/Detail — „links wählen, rechts verwalten". Reine Frontend-Neuordnung; alle
// Aktionen laufen über die bestehenden /api/models-Endpoints (wie im IST-Cockpit).
// Eingebettet als Haupt-Tab im Modell-Manager (Überschrift + Discover liefert ModelsView).
export function ModelsWorkbench() {
const qc = useQueryClient()
const { data: modelsResp, isLoading, error } = useModels(4_000)
const { data: sys } = useSystemStatus()
const { data: groupsResp } = useGroups()
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
const expert = useExpertMode()
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const isProtected = (role?: string | null) => !!roleMeta(role).protected
const brainsGroup = groupsResp?.groups?.brains
const brainsMembers = brainsGroup?.members ?? []
const isCoresident = (name: string) => brainsMembers.includes(name)
const [selName, setSelName] = useState<string | null>(null)
const [query, setQuery] = useState("")
const [filter, setFilter] = useState<"all" | "in_use">("all")
const [specModel, setSpecModel] = useState<ModelInfo | null>(null)
const reload = () => {
qc.invalidateQueries({ queryKey: qk.models })
qc.invalidateQueries({ queryKey: qk.routing })
qc.invalidateQueries({ queryKey: qk.groups })
}
const runningInfo = models.filter((m) => running.includes(m.name))
const gttTotal = sys?.gpu?.gtt_total || sys?.gpu?.vram_total || 0
const gttUsed = sys?.gpu?.gtt_used || 0
const capacity = gttTotal > 2 * B ? gttTotal : 124 * B
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return models
.filter((m) => (filter === "in_use" ? !!m.role || running.includes(m.name) : true))
.filter((m) => (q ? m.name.toLowerCase().includes(q) : true))
.sort((a, b) => {
const aw = running.includes(a.name) ? 0 : 1
const bw = running.includes(b.name) ? 0 : 1
if (aw !== bw) return aw - bw
return short(a.name).localeCompare(short(b.name))
})
}, [models, running, filter, query])
const selected = models.find((m) => m.name === selName) ?? null
// ── Aktionen (IST-Logik) ───────────────────────────────────────────────────
async function doLoad(name: string) {
try { await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" }); reload() }
catch (e: any) { showAlert("Fehler", `Laden fehlgeschlagen: ${e.message || e}`) }
}
async function handleLoad(name: string) {
const brainsWarm = brainsMembers.some((m) => running.includes(m))
if (brainsGroup && brainsWarm && !isCoresident(name)) {
const warm = brainsMembers.filter((m) => running.includes(m)).map(short).join(", ")
showConfirm("Verdrängt das Hirn?",
`${short(name)}" ist nicht ko-resident. Beim Laden wirft es das warme Hirn (${warm}) raus — Lucy verliert Hirn bzw. Augen.\n\nTrotzdem exklusiv laden?`,
() => doLoad(name))
return
}
void doLoad(name)
}
async function handleUnload(name: string) {
const m = models.find((x) => x.name === name)
if (m && isProtected(m.role)) {
showAlert("Geschützt", `${roleMeta(m.role).label}" ist lebenswichtig und bleibt geladen.`)
return
}
try { await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" }); reload() }
catch (e: any) { showAlert("Fehler", `Entladen fehlgeschlagen: ${e.message || e}`) }
}
async function handleRole(role: string, modelName: string) {
try {
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("Rolle gesetzt — Hinweis", res.warning)
} catch (e: any) { showAlert("Fehler", `Rolle zuweisen fehlgeschlagen: ${e.message || e}`) }
}
async function handleCtx(name: string, cur: number | null) {
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 { /* optional */ }
const hint = auto
? `Optimal für dein Setup: ${(auto.ctx / 1024).toFixed(0)}k (${auto.ctx}). »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", `Kontext setzen fehlgeschlagen: ${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}" kann nicht gelöscht werden. Weise die Rolle zuerst einem anderen Modell zu.`)
return
}
showConfirm("Modell löschen?", `${short(name)}" und alle GGUF-Dateien unwiderruflich von der Box löschen?`, async () => {
try { await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" }); if (selName === name) setSelName(null); reload() }
catch (e: any) { showAlert("Fehler", `Löschen fehlgeschlagen: ${e.message || e}`) }
})
}
if (isLoading) return <div className="py-16 text-center text-xs text-muted-foreground">Werkbank wird geladen </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 ({String(error)}).</div>
return (
<div className="space-y-5">
<MaschinenraumBar
running={runningInfo} capacityBytes={capacity} realUsedBytes={gttUsed}
selected={selName} onSelect={setSelName}
/>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
{/* Master — Liste */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mb-3 flex items-center gap-2">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
<input
value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Modell suchen …"
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 pl-8 pr-2 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-primary/50 focus:outline-none"
/>
</div>
<div className="flex rounded-lg border border-border/40 bg-background/30 p-0.5">
{(["all", "in_use"] as const).map((f) => (
<button key={f} onClick={() => setFilter(f)}
className={cn("rounded-md px-2 py-1 text-[9px] font-bold uppercase transition-all cursor-pointer",
filter === f ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
{f === "all" ? "Alle" : "In Benutzung"}
</button>
))}
</div>
</div>
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto scrollbar-thin pr-1">
{filtered.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/50 p-8 text-center text-xs text-muted-foreground">Keine Modelle.</div>
) : filtered.map((m) => {
const warm = running.includes(m.name)
const sel = selName === m.name
const brand = getBrandInfo(m.name)
const c = roleBarColor(m.role)
return (
<button key={m.name} onClick={() => setSelName(m.name)}
className={cn(
"flex w-full items-center gap-2.5 rounded-xl border p-2.5 text-left transition-all cursor-pointer",
sel ? "border-primary/60 bg-primary/10" : "border-border/40 bg-background/20 hover:border-primary/30 hover:bg-background/40",
)}>
<span className={cn("flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border text-[10px] font-bold", brand.color)}>{brand.initial}</span>
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs font-semibold text-foreground">{short(m.name)}</div>
<div className="mt-0.5 flex items-center gap-1.5">
{m.role && <span className={cn("h-2 w-2 rounded-sm", c.dot)} />}
<span className="text-[10px] text-muted-foreground">{m.role ? roleMeta(m.role).short : "keine Rolle"}</span>
<span className="text-[10px] text-muted-foreground/50">· {fmtSize(m.size_bytes)}</span>
</div>
</div>
{warm
? <span className="flex shrink-0 items-center gap-1 text-[9px] font-bold uppercase text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />warm</span>
: m.incomplete
? <span className="shrink-0 text-[9px] font-bold uppercase text-amber-400"> fehlt</span>
: <span className="shrink-0 text-[9px] font-semibold uppercase text-muted-foreground/50">bereit</span>}
</button>
)
})}
</div>
</div>
{/* Detail — verwalten */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
{!selected ? (
<div className="flex h-full min-h-[16rem] flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<Cpu className="h-8 w-8 opacity-40" />
<p className="text-sm font-semibold">Links ein Modell wählen</p>
<p className="text-xs">Dann verwaltest du es hier laden, Rolle, Kontext, löschen.</p>
</div>
) : (
<Detail
m={selected} warm={running.includes(selected.name)} expert={expert}
protectedRole={isProtected(selected.role)} coresident={isCoresident(selected.name)}
onLoad={() => handleLoad(selected.name)} onUnload={() => handleUnload(selected.name)}
onRole={(r) => handleRole(r, selected.name)} onCtx={() => handleCtx(selected.name, selected.ctx)}
onSpec={() => setSpecModel(selected)} onDelete={() => handleDelete(selected.name)}
/>
)}
</div>
</div>
{specModel && <SpecDraftModal model={specModel} onClose={() => setSpecModel(null)} onChanged={reload} />}
{dialogElement}
</div>
)
}
function Detail({
m, warm, expert, protectedRole, coresident, onLoad, onUnload, onRole, onCtx, onSpec, onDelete,
}: {
m: ModelInfo; warm: boolean; expert: boolean; protectedRole: boolean; coresident: boolean
onLoad: () => void; onUnload: () => void; onRole: (r: string) => void; onCtx: () => void; onSpec: () => void; onDelete: () => void
}) {
const brand = getBrandInfo(m.name)
const lockUnload = warm && protectedRole
return (
<div className="flex h-full flex-col gap-4">
{/* Kopf */}
<div className="flex items-start gap-3">
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border text-sm font-bold", brand.color)}>{brand.initial}</span>
<div className="min-w-0 flex-1">
<h2 className="break-all font-mono text-sm font-bold text-foreground">{short(m.name)}</h2>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{m.role ? <RoleLabel role={m.role} /> : <span className="rounded border border-dashed border-border/50 px-1.5 py-0.5 text-[10px] text-muted-foreground">keine Rolle</span>}
{protectedRole && <span className="rounded bg-background/40 px-1.5 py-0.5 text-[10px] text-muted-foreground" title="Lebenswichtig — geschützt"><Lock className="mr-0.5 inline h-3 w-3" />geschützt</span>}
{coresident && <span className="rounded border border-fuchsia-500/30 bg-fuchsia-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase text-fuchsia-300"><Brain className="mr-0.5 inline h-3 w-3" />Ko-resident</span>}
{warm && <span className="flex items-center gap-1 text-[10px] font-bold uppercase text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />warm</span>}
</div>
</div>
</div>
{/* Primär-Aktion: laden / entladen */}
<button
onClick={warm ? onUnload : onLoad}
disabled={(m.incomplete && !warm) || lockUnload}
className={cn(
"flex h-11 w-full items-center justify-center gap-2 rounded-xl border text-sm font-bold transition-all cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
warm ? "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
: "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20",
)}
title={lockUnload ? `${roleMeta(m.role).label}" bleibt geladen (geschützt).` : undefined}
>
{warm ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
{lockUnload ? "🔒 bleibt geladen" : warm ? "Entladen" : "Ins Warm-Set laden"}
</button>
{/* Rolle wählen */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Rolle</div>
<div className="flex flex-wrap gap-1.5">
<RoleChip active={!m.role} onClick={() => onRole("")} dot="bg-slate-400" label="keine" />
{ROLE_META.map((r) => (
<RoleChip key={r.role} active={m.role === r.role} onClick={() => onRole(r.role)}
dot={roleBarColor(r.role).dot} label={r.short} />
))}
</div>
</div>
{/* Kennzahlen */}
<div className="grid grid-cols-2 gap-2 text-xs">
<Metric icon={HardDrive} label="Größe" value={fmtSize(m.size_bytes)} />
<button onClick={onCtx} className="group flex items-center gap-2 rounded-lg border border-border/30 bg-background/20 p-2.5 text-left transition-colors hover:border-primary/40 cursor-pointer">
<Edit3 className="h-4 w-4 text-primary/80" />
<div className="min-w-0">
<div className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Kontext · ändern</div>
<div className="font-semibold text-foreground">{fmtCtx(m.ctx)}</div>
</div>
</button>
</div>
{/* Beschleuniger */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Beschleuniger</span>
{expert && (
<button onClick={onSpec}
className={cn("flex h-6 items-center gap-1 rounded-lg border px-2 text-[9px] font-bold uppercase transition-colors cursor-pointer",
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")}>
<Zap className="h-3 w-3" /> Spec
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{m.spec_active && <Tag tone="emerald" label="Spec-Decoding aktiv" />}
{!m.spec_active && m.spec_draft_model && <Tag tone="amber" label="Spec inaktiv" />}
{m.prompt_cache && <Tag tone="cyan" label="Prompt-Cache" />}
{m.parallel_slots > 1 && <Tag tone="violet" label={`${m.parallel_slots} Slots`} />}
{!m.spec_active && !m.spec_draft_model && !m.prompt_cache && m.parallel_slots <= 1 && (
<span className="text-[11px] text-muted-foreground/60">keine aktiv</span>
)}
</div>
</div>
{/* Fähigkeiten */}
<div className="flex flex-wrap gap-1"><CapsChips caps={m.capabilities} /></div>
{/* Löschen */}
<button onClick={onDelete} disabled={protectedRole}
className={cn("mt-auto flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition-colors",
protectedRole ? "cursor-not-allowed border-border/30 text-muted-foreground/40"
: "border-red-500/30 text-red-400 hover:bg-red-500/10 cursor-pointer")}>
<Trash2 className="h-3.5 w-3.5" /> {protectedRole ? "geschützt — nicht löschbar" : "Modell löschen"}
</button>
</div>
)
}
function RoleChip({ active, onClick, dot, label }: { active: boolean; onClick: () => void; dot: string; label: string }) {
return (
<button onClick={onClick}
className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[10px] font-semibold transition-all cursor-pointer",
active ? "border-primary/60 bg-primary/15 text-foreground" : "border-border/40 bg-background/20 text-muted-foreground hover:border-primary/30")}>
<span className={cn("h-2 w-2 rounded-sm", dot)} />
{label}
{active && <Check className="h-3 w-3 text-primary" />}
</button>
)
}
function Metric({ icon: Icon, label, value }: { icon: typeof HardDrive; label: string; value: string }) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border/30 bg-background/20 p-2.5">
<Icon className="h-4 w-4 text-primary/80" />
<div className="min-w-0">
<div className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">{label}</div>
<div className="font-semibold text-foreground">{value}</div>
</div>
</div>
)
}
function Tag({ tone, label }: { tone: "emerald" | "amber" | "cyan" | "violet"; label: string }) {
const cls = {
emerald: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
amber: "bg-amber-500/10 text-amber-400 border-amber-500/20",
cyan: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20",
violet: "bg-violet-500/10 text-violet-400 border-violet-500/20",
}[tone]
return <span className={cn("rounded border px-1.5 py-0.5 text-[10px] font-semibold", cls)}>{label}</span>
}
@@ -0,0 +1,27 @@
// Voll deckende Balken-/Punkt-Farben je Rolle — abgeleitet aus den Badge-Tönen in
// lib/roleMeta.ts, aber solide (für die Maschinenraum-Speicherleiste). Design-Sprache:
// Hirn=Indigo, Gedächtnis=Smaragd, Augen=Pink, Groß=Bernstein, Coder=Violett/Fuchsia.
export interface RoleBarColor {
bar: string // Segment-Füllung (solide)
dot: string // Legenden-Punkt
text: string // Akzent-Text
}
const BY_ROLE: Record<string, RoleBarColor> = {
hermes: { bar: "bg-indigo-500", dot: "bg-indigo-500", text: "text-indigo-400" },
embed: { bar: "bg-emerald-500", dot: "bg-emerald-500", text: "text-emerald-400" },
vision: { bar: "bg-pink-500", dot: "bg-pink-500", text: "text-pink-400" },
heavy: { bar: "bg-amber-500", dot: "bg-amber-500", text: "text-amber-400" },
"coder-lite": { bar: "bg-violet-500", dot: "bg-violet-500", text: "text-violet-400" },
coder: { bar: "bg-fuchsia-500", dot: "bg-fuchsia-500", text: "text-fuchsia-400" },
scout: { bar: "bg-teal-500", dot: "bg-teal-500", text: "text-teal-400" },
}
const ALIAS: Record<string, string> = { fast: "hermes", coder_lite: "coder-lite" }
const UNASSIGNED: RoleBarColor = { bar: "bg-slate-400", dot: "bg-slate-400", text: "text-slate-300" }
export function roleBarColor(role?: string | null): RoleBarColor {
if (!role) return UNASSIGNED
return BY_ROLE[role] || BY_ROLE[ALIAS[role]] || UNASSIGNED
}