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
+4
View File
@@ -97,6 +97,10 @@ VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:865
# Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI (Ersatz für AnythingLLM-Chat).
# Wird in MC2 per iframe eingebettet (Terminal-Seite). Siehe deploy/hermes-terminal.service.
HERMES_TERMINAL_URL = os.environ.get("MC_HERMES_TERMINAL_URL", "http://192.168.178.151:7681").rstrip("/")
# Box-Konsole: zweites ttyd-Web-Terminal, das eine echte Login-Shell auf der Box öffnet
# (direkter SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Hermes-
# Terminal). Wird in MC2 per iframe eingebettet (Konsole-Seite). Siehe deploy/box-console.service.
BOX_CONSOLE_URL = os.environ.get("MC_BOX_CONSOLE_URL", "http://192.168.178.151:7682").rstrip("/")
# GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
+4 -1
View File
@@ -11,7 +11,7 @@ import re
import httpx
import psutil
from config import HERMES_TERMINAL_URL, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL
from config import HERMES_TERMINAL_URL, BOX_CONSOLE_URL, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL
log = logging.getLogger(__name__)
@@ -141,8 +141,11 @@ def agent_status() -> dict:
"gateway_url": HERMES_API_URL,
# Interaktives Web-Terminal (ttyd → `hermes chat`), eingebettet in MC2.
"terminal_url": HERMES_TERMINAL_URL,
# Box-Konsole (ttyd → Login-Shell): direkter SSH-artiger Box-Zugriff, eingebettet in MC2.
"box_console_url": BOX_CONSOLE_URL,
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
"terminal_reachable": _reach(HERMES_TERMINAL_URL, "/"),
"box_console_reachable": _reach(BOX_CONSOLE_URL, "/"),
"home_exists": home.exists(),
"brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Box-Konsole — ttyd web terminal wrapping a login shell (direkter Box-Zugriff)
Documentation=https://github.com/tsl0922/ttyd
After=network.target
[Service]
# Exponiert eine echte interaktive Login-Shell (bash -l) der Box als Web-Terminal — der
# direkte, SSH-artige Zugriff, den das UI als „Konsole" einbettet (iframe). Schwester-Dienst
# zum hermes-terminal (das die Agent-CLI zeigt); dieser hier zeigt die nackte Shell.
#
# SICHERHEIT: --writable + LAN-Bind ohne Auth = dasselbe Trust-Modell wie das Hermes-Terminal
# und das MC2-Dashboard (vertrautes Heim-LAN, kein Internet-Exposure). Kein Passwort — bewusst,
# analog zum Hermes-Terminal. Für Basic-Auth am ExecStart `--credential <user>:<pass>` ergänzen.
Type=simple
# --interface eno1 = LAN-Bind (box-spezifisch; eno1 trägt 192.168.178.151).
# -t = Terminal-Optionen (dunkles Theme passend zum UI).
ExecStart=/usr/bin/ttyd --writable --interface eno1 --port 7682 --max-clients 2 --cwd %h -t 'theme={"background":"#0b0f1a"}' /bin/bash -l
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
+4
View File
@@ -52,6 +52,8 @@ cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-c
# Hermes-Terminal (ttyd -> `hermes chat`), in MC2 als Terminal-Seite eingebettet.
# Einmalig manuell noetig: `sudo apt install -y ttyd` + `sudo systemctl disable --now ttyd` (apt-Default-Dienst).
cp "$SRC/deploy/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-terminal.service"
# Box-Konsole (ttyd -> Login-Shell auf :7682), in MC2 als Konsole-Seite eingebettet.
cp "$SRC/deploy/box-console.service" "$HOME/.config/systemd/user/box-console.service"
# Mem0-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service"
# Voice-Sidecar-Unit (nur wenn das venv existiert).
@@ -82,6 +84,7 @@ cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
systemctl --user daemon-reload
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
systemctl --user enable hermes-terminal >/dev/null 2>&1 || true
systemctl --user enable box-console >/dev/null 2>&1 || true
systemctl --user enable mem0-service >/dev/null 2>&1 || true
systemctl --user enable voice-service >/dev/null 2>&1 || true
systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
@@ -94,6 +97,7 @@ loginctl enable-linger "$USER" >/dev/null 2>&1 || true
[ -x "$HOME/.voice/venv/bin/python" ] && systemctl --user restart voice-service 2>/dev/null || true
systemctl --user restart mission-control-2
command -v ttyd >/dev/null 2>&1 && systemctl --user restart hermes-terminal 2>/dev/null || true
command -v ttyd >/dev/null 2>&1 && systemctl --user restart box-console 2>/dev/null || true
sleep 2
echo "--- Health ---"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-Bcmk5MTW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bwnys6u-.css">
<script type="module" crossorigin src="/assets/index-DQC1USWi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DfNbRJ2k.css">
</head>
<body>
<div id="root"></div>
+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
}