UI idiotensicher: Klartext-Rollen, Lucy-Statuspanel, Einfach/Experte, Warm-Set
Vier-Etappen-Umbau der MC2-Oberfläche für Endnutzer (kein Fachjargon): 1. Klartext-Schicht (lib/roleMeta.ts): Rollen-Kürzel → Zweck-Namen mit Icons (hermes=Lucys Hirn, embed=Lucys Gedächtnis, vision=Lucys Augen, …), zentral in ModelBadges/ModelsCard/Cockpit. Veraltete Rollenliste korrigiert (Phantom-„fast" raus; embed/coder-lite ergänzt). 2. „Geht's Lucy gut?"-Panel (lib/useLucyHealth.ts + LucyHealthCard): 5 Fähigkeiten in Klartext, Ampel-Verdikt, Speicher-Ampel, 1-Klick-Reparatur (echte Unit-Namen). 3. Einfach/Experte-Schalter (lib/useExpertMode.ts + ExpertToggle) + Schutzgeländer: Hirn/Gedächtnis (protected) nicht löschbar/entladbar; Ctx/Spec/Slots/Lane-Details im Einfach-Modus versteckt. 4. „Immer-bereit-Set"-Manager (WarmSetManager): brains-Gruppe in Klartext + ehrliches Speicher-Budget (inkl. KV) mit Verdrängungs-Hinweis. Live gegen die Box verifiziert. frontend/dist mitgebaut. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
-5481
File diff suppressed because one or more lines are too long
+5513
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DYJjhQkk.js"></script>
|
<script type="module" crossorigin src="/assets/index-DjSNKxZa.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DoqQd4Zi.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-liXV9csI.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from "react"
|
|||||||
import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
|
import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
import { NAV, type ViewId } from "@/nav"
|
import { NAV, type ViewId } from "@/nav"
|
||||||
import { CommandPalette } from "@/components/CommandPalette"
|
import { CommandPalette } from "@/components/CommandPalette"
|
||||||
|
import { ExpertToggle } from "@/components/ExpertToggle"
|
||||||
import { DashboardView } from "@/views/DashboardView"
|
import { DashboardView } from "@/views/DashboardView"
|
||||||
import { ModelsView } from "@/views/ModelsView"
|
import { ModelsView } from "@/views/ModelsView"
|
||||||
import { ConnectView } from "@/views/ConnectView"
|
import { ConnectView } from "@/views/ConnectView"
|
||||||
@@ -195,6 +196,7 @@ export default function App() {
|
|||||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-6 bg-card/20 backdrop-blur-sm">
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-6 bg-card/20 backdrop-blur-sm">
|
||||||
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
|
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<ExpertToggle />
|
||||||
<a
|
<a
|
||||||
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
|
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Sparkles, SlidersHorizontal } from "lucide-react"
|
||||||
|
import { useExpertMode, setExpertMode } from "@/lib/useExpertMode"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Zwei-Zustand-Pille im Header: „Einfach" (aufgeräumt) ↔ „Experte" (alle Details).
|
||||||
|
export function ExpertToggle() {
|
||||||
|
const expert = useExpertMode()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center rounded-md border border-border/40 bg-background/40 p-0.5"
|
||||||
|
role="group"
|
||||||
|
aria-label="Ansichtsmodus"
|
||||||
|
title="Einfach zeigt nur das Wichtigste. Experte zeigt alle technischen Details."
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpertMode(false)}
|
||||||
|
aria-pressed={!expert}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
|
||||||
|
!expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5" /> Einfach
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpertMode(true)}
|
||||||
|
aria-pressed={expert}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
|
||||||
|
expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-3.5 w-3.5" /> Experte
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react"
|
||||||
|
import { api } from "@/lib/api"
|
||||||
|
import { useDialog } from "@/lib/useDialog"
|
||||||
|
import { useQueryClient, qk } from "@/lib/queries"
|
||||||
|
import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Farb-/Text-Ton pro Check-Status.
|
||||||
|
const DOT: Record<string, string> = {
|
||||||
|
ok: "bg-emerald-500",
|
||||||
|
warn: "bg-amber-500",
|
||||||
|
down: "bg-red-500",
|
||||||
|
loading: "bg-muted-foreground/40",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LucyHealthCard() {
|
||||||
|
const { verdict, checks, memory, problems } = 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)." : ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert.
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banner-Optik je Gesamt-Verdikt.
|
||||||
|
const banner = {
|
||||||
|
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: "Lucy wird geprüft …", sub: "Einen Moment, ich schaue nach dem Rechten." },
|
||||||
|
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: "Lucy geht's gut 💚", sub: "Alle wichtigen Fähigkeiten laufen." },
|
||||||
|
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: "Kleinigkeit bei Lucy", sub: "Nichts Schlimmes — nur ein Hinweis." },
|
||||||
|
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: "Lucy braucht Hilfe", sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` },
|
||||||
|
}[verdict]
|
||||||
|
const BannerIcon = banner.icon
|
||||||
|
|
||||||
|
const memTone = {
|
||||||
|
ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40",
|
||||||
|
}[memory.status]
|
||||||
|
const memText = {
|
||||||
|
ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground",
|
||||||
|
}[memory.status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors", banner.ring)}>
|
||||||
|
{/* Gesamt-Verdikt */}
|
||||||
|
<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">
|
||||||
|
<BannerIcon className={cn("h-6 w-6", banner.iconCls)} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-base font-bold tracking-tight text-foreground">{banner.title}</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{banner.sub}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speicher-Ampel */}
|
||||||
|
<div className="mt-4 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", memText)}>
|
||||||
|
{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", memTone)} style={{ width: `${memory.pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className={cn("mt-1.5 text-[11px]", memText)}>{memory.text}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fähigkeiten-Checkliste */}
|
||||||
|
<div className="mt-4 space-y-1.5">
|
||||||
|
{checks.map((c) => (
|
||||||
|
<CheckRow key={c.id} c={c} busy={!!busy[c.id]} onRepair={() => c.repair && runRepair(c.id, c.repair)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dialogElement}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) {
|
||||||
|
const Icon = c.icon
|
||||||
|
const bad = c.status === "down" || c.status === "warn"
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-3 rounded-xl border p-2.5 transition-colors",
|
||||||
|
c.status === "down" ? "border-red-500/25 bg-red-500/5"
|
||||||
|
: c.status === "warn" ? "border-amber-500/20 bg-amber-500/5"
|
||||||
|
: "border-border/30 bg-background/15",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-foreground/80">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className={cn("absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[c.status], c.status === "down" && "animate-pulse")} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-bold text-foreground">
|
||||||
|
{c.label}
|
||||||
|
{c.status === "ok" && <Check className="h-3 w-3 text-emerald-400" />}
|
||||||
|
{!c.critical && <span className="rounded bg-background/40 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-muted-foreground/60">optional</span>}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[11px] text-muted-foreground">{c.detail}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bad && c.repair && (
|
||||||
|
<button
|
||||||
|
onClick={onRepair}
|
||||||
|
disabled={busy}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-60",
|
||||||
|
c.status === "down"
|
||||||
|
? "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"
|
||||||
|
: "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20",
|
||||||
|
)}
|
||||||
|
title={c.repair.label}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
|
||||||
|
{c.repair.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{bad && !c.repair && (
|
||||||
|
<span className="flex shrink-0 items-center gap-1 text-[10px] font-semibold text-muted-foreground" title="Keine Auto-Reparatur bekannt">
|
||||||
|
<ShieldQuestion className="h-3.5 w-3.5" /> manuell
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Boxes, Zap, Moon } from "lucide-react"
|
|||||||
import { useModels, useTokenStats, useSystemStatus } from "@/lib/queries"
|
import { useModels, useTokenStats, useSystemStatus } from "@/lib/queries"
|
||||||
import { fmtSize, gb } from "@/lib/format"
|
import { fmtSize, gb } from "@/lib/format"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ROLES, roleTone } from "@/components/models/ModelBadges"
|
import { ROLES, RoleLabel } from "@/components/models/ModelBadges"
|
||||||
|
|
||||||
// Vereint die frühere RolesCard (Rolle→Modell) + ActiveModelsCard (warm/Inferenz/Größe)
|
// Vereint die frühere RolesCard (Rolle→Modell) + ActiveModelsCard (warm/Inferenz/Größe)
|
||||||
// zu EINER Karte — dieselbe Info stand vorher doppelt auf der Zentrale.
|
// zu EINER Karte — dieselbe Info stand vorher doppelt auf der Zentrale.
|
||||||
@@ -85,9 +85,7 @@ export function ModelsCard() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className={cn("text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded font-mono border tracking-wider shrink-0", roleTone(role))}>
|
<RoleLabel role={role} className="shrink-0" />
|
||||||
{role}
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="text-xs font-semibold truncate font-mono text-foreground">
|
<span className="text-xs font-semibold truncate font-mono text-foreground">
|
||||||
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
|
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
import { type Fit } from "@/lib/api"
|
import { type Fit } from "@/lib/api"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ROLES, roleTone, roleMeta } from "@/lib/roleMeta"
|
||||||
|
|
||||||
// Die kanonischen Serving-Rollen (identisch zu Backend sources.py/llamaswap.ROLE_IDS).
|
// Klartext-Schicht: Rollen-Namen/Icons/Farben leben jetzt zentral in lib/roleMeta.ts.
|
||||||
// `hermes` = Lucys Agent-Hirn (warm + ko-resident); UI-Label „Hirn".
|
// Re-Export hält bestehende Importe (Cockpit, ModelsCard, …) stabil.
|
||||||
export const ROLES = ["fast", "heavy", "coder", "vision", "hermes", "scout"]
|
export { ROLES, roleTone, roleMeta }
|
||||||
|
|
||||||
// Rolle → Farbton. EINE Quelle der Wahrheit für alle Rollen-Badges (Cockpit, ModelsCard,
|
// Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip.
|
||||||
// …). `hermes` = Agent-Hirn (taucht als geladenes Modell auf).
|
// `dense` = nur Kurzform (für enge Badges).
|
||||||
export const ROLE_TONE: Record<string, string> = {
|
export function RoleLabel({
|
||||||
fast: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25",
|
role,
|
||||||
heavy: "bg-amber-500/15 text-amber-400 border-amber-500/25",
|
dense = false,
|
||||||
coder: "bg-violet-500/15 text-violet-400 border-violet-500/25",
|
className,
|
||||||
vision: "bg-pink-500/15 text-pink-400 border-pink-500/25",
|
}: {
|
||||||
scout: "bg-teal-500/15 text-teal-400 border-teal-500/25",
|
role?: string | null
|
||||||
hermes: "bg-indigo-500/15 text-indigo-400 border-indigo-500/25",
|
dense?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const meta = roleMeta(role)
|
||||||
|
const Icon = meta.icon
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border tracking-wide",
|
||||||
|
meta.tone,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={`${meta.desc} (${meta.role})`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
|
{dense ? meta.short : meta.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const roleTone = (role?: string | null) =>
|
|
||||||
(role && ROLE_TONE[role]) || "bg-slate-500/15 text-slate-300 border-slate-500/25"
|
|
||||||
|
|
||||||
export function FitBadge({ fit }: { fit: Fit }) {
|
export function FitBadge({ fit }: { fit: Fit }) {
|
||||||
const tone = {
|
const tone = {
|
||||||
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
|
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react"
|
||||||
|
import { setGroup, type ModelInfo } from "@/lib/api"
|
||||||
|
import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
|
||||||
|
import { useDialog } from "@/lib/useDialog"
|
||||||
|
import { RoleLabel, roleMeta } from "@/components/models/ModelBadges"
|
||||||
|
import { fmtSize } from "@/lib/format"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder
|
||||||
|
// bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar,
|
||||||
|
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
|
||||||
|
export function WarmSetManager() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { data: modelsResp } = useModels(4_000)
|
||||||
|
const { data: groupsResp } = useGroups()
|
||||||
|
const { data: sysStatus } = useSystemStatus()
|
||||||
|
const { data: brain } = useHermesBrain()
|
||||||
|
const { showAlert, dialogElement } = useDialog()
|
||||||
|
const [picking, setPicking] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const models = modelsResp?.models ?? []
|
||||||
|
const running = modelsResp?.running ?? []
|
||||||
|
const group = groupsResp?.groups?.brains
|
||||||
|
const members = group?.members ?? []
|
||||||
|
const byName = (name: string) => models.find((m) => m.name === name)
|
||||||
|
const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name
|
||||||
|
|
||||||
|
const memberModels = members.map(byName).filter(Boolean) as ModelInfo[]
|
||||||
|
const B = 1024 ** 3
|
||||||
|
const bud = brain?.budget
|
||||||
|
const weightsGb = memberModels.reduce((a, m) => a + (m.size_bytes || 0), 0) / B
|
||||||
|
const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
|
||||||
|
const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0)
|
||||||
|
// Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte.
|
||||||
|
const reservedGb = bud?.warm_projected_gb ?? weightsGb
|
||||||
|
const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0
|
||||||
|
|
||||||
|
const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete)
|
||||||
|
|
||||||
|
async function save(next: string[]) {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true)
|
||||||
|
qc.invalidateQueries({ queryKey: qk.groups })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.models })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.hermesBrain })
|
||||||
|
} catch (e: any) {
|
||||||
|
showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(name: string) {
|
||||||
|
const m = byName(name)
|
||||||
|
if (m && roleMeta(m.role).protected) {
|
||||||
|
showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void save(members.filter((x) => x !== name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(name: string) {
|
||||||
|
setPicking(false)
|
||||||
|
void save([...members, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
|
||||||
|
<Header />
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
|
Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist.
|
||||||
|
</p>
|
||||||
|
{dialogElement}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* Mitglieder */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{memberModels.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div>
|
||||||
|
)}
|
||||||
|
{memberModels.map((m) => {
|
||||||
|
const isWarm = running.includes(m.name)
|
||||||
|
const prot = roleMeta(m.role).protected
|
||||||
|
return (
|
||||||
|
<div key={m.name} className="flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<span className={cn("h-2 w-2 shrink-0 rounded-full ring-2 ring-black/40", isWarm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")}
|
||||||
|
title={isWarm ? "Gerade warm (bereit)" : "Reserviert — lädt bei Bedarf sofort"} />
|
||||||
|
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
|
||||||
|
<span className="truncate font-mono text-xs font-semibold text-foreground" title={m.name}>{shortName(m.name)}</span>
|
||||||
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
{prot ? (
|
||||||
|
<span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => remove(m.name)}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground transition-colors hover:bg-red-500/5 hover:text-red-400 cursor-pointer disabled:opacity-50"
|
||||||
|
title="Aus dem Immer-bereit-Set nehmen (lädt dann nur noch bei Bedarf)"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hinzufügen */}
|
||||||
|
<button
|
||||||
|
onClick={() => setPicking(true)}
|
||||||
|
disabled={busy || eligible.length === 0}
|
||||||
|
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/50 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Modell dauerhaft bereithalten
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Speicher-Budget */}
|
||||||
|
<div className="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" title="Modellgewichte + Arbeitsspeicher (KV-Cache)">
|
||||||
|
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-bold text-foreground">
|
||||||
|
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${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", pct >= 85 ? "bg-red-500" : pct >= 65 ? "bg-amber-500" : "bg-teal-500")} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bud && !bud.fits && (
|
||||||
|
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB, z. B. das große Hirn) passt das Set
|
||||||
|
nicht gleichzeitig in den Speicher. Beim Laden wird das Set kurz verdrängt und danach automatisch nachgeladen — kostet ein paar Sekunden.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{bud && bud.fits && (
|
||||||
|
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-400">
|
||||||
|
<Check className="h-3.5 w-3.5 shrink-0" /> Passt auch neben dem größten Gelegenheits-Modell — nichts wird verdrängt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auswahl-Modal */}
|
||||||
|
{picking && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Modell zum Immer-bereit-Set hinzufügen">
|
||||||
|
<div className="w-full max-w-md space-y-3 rounded-2xl border border-border/80 bg-card p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">Dauerhaft bereithalten</h3>
|
||||||
|
<button onClick={() => setPicking(false)} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Wähle ein Modell, das ohne Ladezeit bereitstehen soll:</p>
|
||||||
|
<div className="max-h-60 space-y-1.5 overflow-y-auto pr-1">
|
||||||
|
{eligible.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.name}
|
||||||
|
onClick={() => add(m.name)}
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/30 bg-background/20 px-3 py-2.5 text-left transition-colors hover:bg-accent cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
|
||||||
|
<span className="truncate font-mono text-xs font-semibold text-foreground">{shortName(m.name)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialogElement}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="h-4.5 w-4.5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-wide text-foreground">Immer-bereit-Set</h2>
|
||||||
|
<p className="text-[11px] text-muted-foreground">Diese Modelle hält Lucy immer bereit — sie antworten ohne Ladezeit.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
BrainCog,
|
||||||
|
Code,
|
||||||
|
Eye,
|
||||||
|
BookMarked,
|
||||||
|
Telescope,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
// ── EINE Quelle der Wahrheit für ALLE Serving-Rollen ────────────────────────
|
||||||
|
// Klartext statt Kürzel: jede Rolle bekommt einen Zweck-Namen, ein Icon und
|
||||||
|
// eine Ein-Satz-Erklärung, damit die UI ohne Fachjargon verständlich ist.
|
||||||
|
// Das technische Kürzel (`role`) bleibt für den späteren Experten-Modus erhalten.
|
||||||
|
//
|
||||||
|
// Die Rollen hier spiegeln den ECHTEN Stand der Box (/api/models → role):
|
||||||
|
// hermes · heavy · coder · coder-lite · vision · embed · scout
|
||||||
|
// (Kein Phantom-`fast`: das ist nur ein Lane-Alias, keine Modell-Rolle. Lucys
|
||||||
|
// Alltags-Hirn ist die Rolle `hermes` = Qwen3.6.)
|
||||||
|
|
||||||
|
export interface RoleMeta {
|
||||||
|
role: string // technisches Kürzel (Experten-Modus)
|
||||||
|
label: string // Klartext-Zweckname
|
||||||
|
short: string // Kurzform für enge Badges
|
||||||
|
icon: LucideIcon
|
||||||
|
desc: string // Tooltip / Erklärung in einfacher Sprache
|
||||||
|
tone: string // Farbklassen fürs Badge
|
||||||
|
protected?: boolean // Hirn & Gedächtnis: lebenswichtig → nicht löschbar/entladbar (Etappe 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reihenfolge = Anzeige-Reihenfolge in den Slot-Übersichten (Wichtigstes zuerst).
|
||||||
|
export const ROLE_META: RoleMeta[] = [
|
||||||
|
{
|
||||||
|
role: "hermes",
|
||||||
|
label: "Lucys Hirn",
|
||||||
|
short: "Hirn",
|
||||||
|
icon: Brain,
|
||||||
|
desc: "Lucys Alltags-Verstand — denkt und redet mit dir. Immer bereit.",
|
||||||
|
tone: "bg-indigo-500/15 text-indigo-400 border-indigo-500/25",
|
||||||
|
protected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "embed",
|
||||||
|
label: "Lucys Gedächtnis",
|
||||||
|
short: "Gedächtnis",
|
||||||
|
icon: BookMarked,
|
||||||
|
desc: "Merkt sich Wichtiges und findet es später wieder.",
|
||||||
|
tone: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
|
||||||
|
protected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "vision",
|
||||||
|
label: "Lucys Augen",
|
||||||
|
short: "Augen",
|
||||||
|
icon: Eye,
|
||||||
|
desc: "Sieht Bilder und Screenshots und beschreibt, was drauf ist.",
|
||||||
|
tone: "bg-pink-500/15 text-pink-400 border-pink-500/25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "heavy",
|
||||||
|
label: "Großes Hirn",
|
||||||
|
short: "Groß",
|
||||||
|
icon: BrainCog,
|
||||||
|
desc: "Das schwere Geschütz — wird für besonders knifflige Fragen dazugeholt.",
|
||||||
|
tone: "bg-amber-500/15 text-amber-400 border-amber-500/25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "coder-lite",
|
||||||
|
label: "Programmierer (schnell)",
|
||||||
|
short: "Code schnell",
|
||||||
|
icon: Code,
|
||||||
|
desc: "Schnelles Programmieren im Alltag.",
|
||||||
|
tone: "bg-violet-500/15 text-violet-400 border-violet-500/25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "coder",
|
||||||
|
label: "Programmierer (gründlich)",
|
||||||
|
short: "Code gründlich",
|
||||||
|
icon: Code,
|
||||||
|
desc: "Schreibt und prüft Code besonders sorgfältig — für schwere Coding-Aufgaben.",
|
||||||
|
tone: "bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "scout",
|
||||||
|
label: "Späher",
|
||||||
|
short: "Späher",
|
||||||
|
icon: Telescope,
|
||||||
|
desc: "Schneller erster Blick und grobe Einschätzung.",
|
||||||
|
tone: "bg-teal-500/15 text-teal-400 border-teal-500/25",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Kanonische Rollen-Reihenfolge (ersetzt die alte, veraltete ROLES-Liste).
|
||||||
|
export const ROLES: string[] = ROLE_META.map((m) => m.role)
|
||||||
|
|
||||||
|
const BY_ROLE: Record<string, RoleMeta> = Object.fromEntries(
|
||||||
|
ROLE_META.map((m) => [m.role, m]),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fallback für unbekannte/neue Rollen — nie „undefined", damit die UID nicht bricht.
|
||||||
|
const UNKNOWN = (role?: string | null): RoleMeta => ({
|
||||||
|
role: role || "?",
|
||||||
|
label: role || "unbekannt",
|
||||||
|
short: role || "?",
|
||||||
|
icon: Brain,
|
||||||
|
desc: "Rolle ohne hinterlegten Klartext-Namen.",
|
||||||
|
tone: "bg-slate-500/15 text-slate-300 border-slate-500/25",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Aliase, die auf eine echte Rolle zeigen (z.B. Lane-Alias `fast` = Hirn).
|
||||||
|
const ALIAS: Record<string, string> = {
|
||||||
|
fast: "hermes",
|
||||||
|
coder_lite: "coder-lite",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleMeta(role?: string | null): RoleMeta {
|
||||||
|
if (!role) return UNKNOWN(role)
|
||||||
|
return BY_ROLE[role] || BY_ROLE[ALIAS[role]] || UNKNOWN(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klartext-Name einer Rolle (mit optionaler Kurzform).
|
||||||
|
export const roleLabel = (role?: string | null, short = false) =>
|
||||||
|
short ? roleMeta(role).short : roleMeta(role).label
|
||||||
|
|
||||||
|
// Farbton — bleibt kompatibel zur bisherigen roleTone()-Signatur.
|
||||||
|
export const roleTone = (role?: string | null) => roleMeta(role).tone
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useSyncExternalStore } from "react"
|
||||||
|
|
||||||
|
// Globaler „Einfach/Experte"-Schalter. Einfach (Default) blendet technische
|
||||||
|
// Details (Kontext, Spec, Slots, Ko-Residenz, Lane-Routing …) aus, damit die UI
|
||||||
|
// nicht überfordert. In localStorage gemerkt; alle Consumer aktualisieren via Event.
|
||||||
|
|
||||||
|
const KEY = "mc_expert_mode"
|
||||||
|
const EVT = "mc-expert-mode-change"
|
||||||
|
|
||||||
|
const read = (): boolean => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) === "true"
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
window.addEventListener(EVT, cb)
|
||||||
|
window.addEventListener("storage", cb)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(EVT, cb)
|
||||||
|
window.removeEventListener("storage", cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setExpertMode(v: boolean) {
|
||||||
|
localStorage.setItem(KEY, String(v))
|
||||||
|
window.dispatchEvent(new Event(EVT))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExpertMode(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, read, () => false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// „Geht's Lucy gut?" — bündelt die vielen technischen Status-Signale zu wenigen
|
||||||
|
// Lucy-Fähigkeiten in Klartext, inkl. Gesamt-Verdikt, Speicher-Ampel und einer
|
||||||
|
// konkreten 1-Klick-Reparatur pro Problem. EINE Quelle für das Status-Panel.
|
||||||
|
|
||||||
|
import { Brain, BookMarked, Plug, Bot, Mic, type LucideIcon } from "lucide-react"
|
||||||
|
import { useHealth, useServices, useModels, useSystemStatus } from "./queries"
|
||||||
|
|
||||||
|
export type CheckStatus = "ok" | "warn" | "down" | "loading"
|
||||||
|
|
||||||
|
// Reparatur-Rezept: entweder einen systemd-Dienst neu starten oder ein Modell laden.
|
||||||
|
// WICHTIG: `service` sind die ECHTEN Unit-Namen (nicht die Anzeige-Namen der
|
||||||
|
// Dienste-Karte), damit /api/maintenance/restart sie akzeptiert.
|
||||||
|
export type Repair =
|
||||||
|
| { kind: "restart"; service: string; label: string; needsSudo?: boolean }
|
||||||
|
| { kind: "loadModel"; model: string; label: string }
|
||||||
|
|
||||||
|
export interface LucyCheck {
|
||||||
|
id: string
|
||||||
|
label: string // Klartext-Name der Fähigkeit
|
||||||
|
icon: LucideIcon
|
||||||
|
status: CheckStatus
|
||||||
|
detail: string // Ein Satz: was ist gerade los
|
||||||
|
critical: boolean // zählt fürs Gesamt-Verdikt „Problem"
|
||||||
|
repair?: Repair
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Verdict = "loading" | "gut" | "warn" | "problem"
|
||||||
|
|
||||||
|
export interface MemoryAmpel {
|
||||||
|
status: "ok" | "warn" | "full" | "unknown"
|
||||||
|
usedGb: number
|
||||||
|
totalGb: number
|
||||||
|
pct: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LucyHealth {
|
||||||
|
verdict: Verdict
|
||||||
|
checks: LucyCheck[]
|
||||||
|
memory: MemoryAmpel
|
||||||
|
problems: LucyCheck[] // kritische Down-Checks (für den Schnellzugriff im Banner)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLucyHealth(): LucyHealth {
|
||||||
|
const { data: health } = useHealth()
|
||||||
|
const { data: svc } = useServices()
|
||||||
|
const { data: modelsResp } = useModels()
|
||||||
|
const { data: sys } = useSystemStatus()
|
||||||
|
|
||||||
|
const loaded = !!health && !!svc
|
||||||
|
const svcOk = (frag: string): boolean | undefined =>
|
||||||
|
svc?.services.find((s) => s.name.toLowerCase().includes(frag))?.ok
|
||||||
|
|
||||||
|
const engineOk = health?.engine_reachable
|
||||||
|
const brainReady = health?.brain?.ready
|
||||||
|
const brainName =
|
||||||
|
health?.brain?.model ||
|
||||||
|
modelsResp?.models.find((m) => m.role === "hermes")?.name ||
|
||||||
|
""
|
||||||
|
|
||||||
|
const checks: LucyCheck[] = []
|
||||||
|
|
||||||
|
// 1) Lucys Hirn — die wichtigste Fähigkeit. Engine läuft UND das Hirn-Modell ist geladen?
|
||||||
|
checks.push(((): LucyCheck => {
|
||||||
|
const base = { id: "brain", label: "Lucys Hirn", icon: Brain, critical: true }
|
||||||
|
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||||
|
if (engineOk === false)
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "down",
|
||||||
|
detail: "Die Motor-Maschine (Engine) läuft nicht — Lucy kann gerade gar nicht denken.",
|
||||||
|
repair: { kind: "restart", service: "llama-swap", label: "Motor neu starten", needsSudo: true },
|
||||||
|
}
|
||||||
|
if (brainReady === false)
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "down",
|
||||||
|
detail: "Lucys Hirn ist gerade eingeschlafen — einmal aufwecken.",
|
||||||
|
repair: brainName
|
||||||
|
? { kind: "loadModel", model: brainName, label: "Hirn aufwecken" }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
return { ...base, status: "ok", detail: "Wach und ansprechbar." }
|
||||||
|
})())
|
||||||
|
|
||||||
|
// 2) Lucys Gedächtnis — Mem0-Dienst.
|
||||||
|
checks.push(((): LucyCheck => {
|
||||||
|
const base = { id: "memory", label: "Lucys Gedächtnis", icon: BookMarked, critical: true }
|
||||||
|
const ok = svcOk("mem0")
|
||||||
|
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||||
|
return ok
|
||||||
|
? { ...base, status: "ok", detail: "Merkt sich Neues und findet Altes wieder." }
|
||||||
|
: {
|
||||||
|
...base,
|
||||||
|
status: "down",
|
||||||
|
detail: "Lucy kann sich gerade nichts merken — das Gedächtnis antwortet nicht.",
|
||||||
|
repair: { kind: "restart", service: "mem0-service", label: "Gedächtnis neu starten" },
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
|
||||||
|
// 3) Verbindung (Gateway) — der integrierte OpenAI-Gateway, über den Apps/IDEs Lucy erreichen.
|
||||||
|
checks.push(((): LucyCheck => {
|
||||||
|
const base = { id: "gateway", label: "Verbindung (Gateway)", icon: Plug, critical: true }
|
||||||
|
const ok = health ? health.gateway_reachable : svcOk("integriert")
|
||||||
|
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||||
|
return ok
|
||||||
|
? { ...base, status: "ok", detail: "Apps und IDEs können Lucy erreichen." }
|
||||||
|
: {
|
||||||
|
...base,
|
||||||
|
status: "down",
|
||||||
|
detail: "Die Verbindungs-Zentrale antwortet nicht — Apps erreichen Lucy nicht.",
|
||||||
|
repair: { kind: "restart", service: "mission-control-2", label: "Zentrale neu starten" },
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
|
||||||
|
// 4) Lucys Agent (Hermes) — der Dienst, der als Lucy antwortet (Terminal/Telegram/Sprechen).
|
||||||
|
checks.push(((): LucyCheck => {
|
||||||
|
const base = { id: "agent", label: "Lucys Agent (Hermes)", icon: Bot, critical: true }
|
||||||
|
const ok = svcOk("hermes-gateway")
|
||||||
|
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||||
|
return ok
|
||||||
|
? { ...base, status: "ok", detail: "Lucy ist bereit zu reden und zu handeln." }
|
||||||
|
: {
|
||||||
|
...base,
|
||||||
|
status: "down",
|
||||||
|
detail: "Lucys Agent ist offline — sie reagiert gerade nicht.",
|
||||||
|
repair: { kind: "restart", service: "hermes-gateway", label: "Agent neu starten" },
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
|
||||||
|
// 5) Lucys Stimme — Voice-Dienst (STT/TTS). Nicht kritisch: Chat geht auch ohne.
|
||||||
|
checks.push(((): LucyCheck => {
|
||||||
|
const base = { id: "voice", label: "Lucys Stimme", icon: Mic, critical: false }
|
||||||
|
const ok = svcOk("voice")
|
||||||
|
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||||
|
return ok
|
||||||
|
? { ...base, status: "ok", detail: "Lucy kann hören und sprechen." }
|
||||||
|
: {
|
||||||
|
...base,
|
||||||
|
status: "warn",
|
||||||
|
detail: "Sprechen ist gerade aus — Tippen geht weiter normal.",
|
||||||
|
repair: { kind: "restart", service: "voice-service", label: "Stimme neu starten" },
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
|
||||||
|
// Speicher-Ampel aus dem echten GTT-Pool (unified RAM inkl. KV-Cache).
|
||||||
|
const total = sys?.gpu?.gtt_total || sys?.gpu?.vram_total || 0
|
||||||
|
const used = sys?.gpu?.gtt_used || 0
|
||||||
|
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0
|
||||||
|
const B = 1024 ** 3
|
||||||
|
const memory: MemoryAmpel = !total
|
||||||
|
? { status: "unknown", usedGb: 0, totalGb: 0, pct: 0, text: "Speicher-Auslastung unbekannt." }
|
||||||
|
: pct >= 90
|
||||||
|
? { status: "full", usedGb: used / B, totalGb: total / B, pct, text: "Speicher fast voll — Lucy könnte langsamer werden." }
|
||||||
|
: pct >= 72
|
||||||
|
? { status: "warn", usedGb: used / B, totalGb: total / B, pct, text: "Speicher gut gefüllt — noch okay." }
|
||||||
|
: { status: "ok", usedGb: used / B, totalGb: total / B, pct, text: "Genug Speicher frei." }
|
||||||
|
|
||||||
|
const anyLoading = checks.some((c) => c.status === "loading")
|
||||||
|
const problems = checks.filter((c) => c.critical && c.status === "down")
|
||||||
|
const warns = checks.filter((c) => c.status === "warn" || (!c.critical && c.status === "down"))
|
||||||
|
const memBad = memory.status === "full"
|
||||||
|
|
||||||
|
const verdict: Verdict = anyLoading && !problems.length
|
||||||
|
? "loading"
|
||||||
|
: problems.length
|
||||||
|
? "problem"
|
||||||
|
: warns.length || memBad
|
||||||
|
? "warn"
|
||||||
|
: "gut"
|
||||||
|
|
||||||
|
return { verdict, checks, memory, problems }
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCar
|
|||||||
import { ServicesCard } from "@/components/dashboard/ServicesCard"
|
import { ServicesCard } from "@/components/dashboard/ServicesCard"
|
||||||
import { VoiceLatencyCard } from "@/components/dashboard/VoiceLatencyCard"
|
import { VoiceLatencyCard } from "@/components/dashboard/VoiceLatencyCard"
|
||||||
import { ModelsCard } from "@/components/dashboard/ModelsCard"
|
import { ModelsCard } from "@/components/dashboard/ModelsCard"
|
||||||
|
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
|
||||||
|
|
||||||
function ZoneLabel({ children }: { children: React.ReactNode }) {
|
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>
|
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
|
||||||
@@ -21,6 +22,9 @@ export function DashboardView() {
|
|||||||
<p className="text-sm text-muted-foreground">Aktueller Status von System, Modellen und Agent.</p>
|
<p className="text-sm text-muted-foreground">Aktueller Status von System, Modellen und Agent.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* „Geht's Lucy gut?" — Klartext-Ampel + 1-Klick-Reparatur, ganz oben. */}
|
||||||
|
<LucyHealthCard />
|
||||||
|
|
||||||
{/* Hero-Banner — System, Durchsatz, Modelle (Rollen + warm + Inferenz in einer Karte) */}
|
{/* Hero-Banner — System, Durchsatz, Modelle (Rollen + warm + Inferenz in einer Karte) */}
|
||||||
<div className="grid items-stretch gap-6 lg:grid-cols-3">
|
<div className="grid items-stretch gap-6 lg:grid-cols-3">
|
||||||
<SystemStatusCard />
|
<SystemStatusCard />
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { useDialog } from "@/lib/useDialog"
|
|||||||
import { CapsChips } from "@/components/CapsChips"
|
import { CapsChips } from "@/components/CapsChips"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { fmtSize, fmtCtx } from "@/lib/format"
|
import { fmtSize, fmtCtx } from "@/lib/format"
|
||||||
import { getBrandInfo, roleTone, ROLES } from "@/components/models/ModelBadges"
|
import { getBrandInfo, ROLES, RoleLabel, roleMeta } from "@/components/models/ModelBadges"
|
||||||
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
||||||
import { LaneEditor } from "@/components/models/LaneEditor"
|
import { LaneEditor } from "@/components/models/LaneEditor"
|
||||||
|
import { WarmSetManager } from "@/components/models/WarmSetManager"
|
||||||
|
import { useExpertMode } from "@/lib/useExpertMode"
|
||||||
|
|
||||||
export function Cockpit() {
|
export function Cockpit() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -18,6 +20,10 @@ export function Cockpit() {
|
|||||||
const { data: sysStatus } = useSystemStatus()
|
const { data: sysStatus } = useSystemStatus()
|
||||||
const { data: groupsResp } = useGroups()
|
const { data: groupsResp } = useGroups()
|
||||||
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
|
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
|
||||||
|
const expert = useExpertMode()
|
||||||
|
// Schutzgeländer: Modelle in einer lebenswichtigen Rolle (Hirn/Gedächtnis) dürfen
|
||||||
|
// nicht gelöscht/entladen werden. Das `protected`-Flag kommt zentral aus roleMeta.
|
||||||
|
const isProtected = (role?: string | null) => !!roleMeta(role).protected
|
||||||
const models = modelsResp?.models ?? []
|
const models = modelsResp?.models ?? []
|
||||||
const running = modelsResp?.running ?? []
|
const running = modelsResp?.running ?? []
|
||||||
const error = loadErr ? String(loadErr) : ""
|
const error = loadErr ? String(loadErr) : ""
|
||||||
@@ -88,6 +94,11 @@ export function Cockpit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleUnloadModel(name: string) {
|
async function handleUnloadModel(name: string) {
|
||||||
|
const m = models.find((x) => x.name === name)
|
||||||
|
if (m && isProtected(m.role)) {
|
||||||
|
showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig für Lucy und bleibt geladen. Zum Entladen erst im Experten-Modus die Rolle einem anderen Modell geben.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
|
await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
|
||||||
reload()
|
reload()
|
||||||
@@ -164,6 +175,11 @@ export function Cockpit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(name: string) {
|
async function handleDelete(name: string) {
|
||||||
|
const m = models.find((x) => x.name === name)
|
||||||
|
if (m && isProtected(m.role)) {
|
||||||
|
showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig für Lucy und kann nicht gelöscht werden. Weise die Rolle zuerst einem anderen Modell zu.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
showConfirm(
|
showConfirm(
|
||||||
"Modell löschen?",
|
"Modell löschen?",
|
||||||
`Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`,
|
`Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`,
|
||||||
@@ -258,7 +274,7 @@ export function Cockpit() {
|
|||||||
<span className="text-[10px] font-mono text-muted-foreground">
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
Speicher-Pool: {fmtSize(totalRunningSize)} Gewichte{gttUsed > 0 ? ` · ${fmtSize(gttUsed)} real belegt (inkl. KV)` : ""} / {fmtSize(capacity)}
|
Speicher-Pool: {fmtSize(totalRunningSize)} Gewichte{gttUsed > 0 ? ` · ${fmtSize(gttUsed)} real belegt (inkl. KV)` : ""} / {fmtSize(capacity)}
|
||||||
</span>
|
</span>
|
||||||
{running.length > 0 && (
|
{expert && running.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleUnloadAll}
|
onClick={handleUnloadAll}
|
||||||
className="h-6 px-2.5 rounded border border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10 text-amber-400 text-[9px] font-bold uppercase transition-all cursor-pointer"
|
className="h-6 px-2.5 rounded border border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10 text-amber-400 text-[9px] font-bold uppercase transition-all cursor-pointer"
|
||||||
@@ -295,7 +311,7 @@ export function Cockpit() {
|
|||||||
title={`${m.name} (${fmtSize(m.size_bytes)})`}
|
title={`${m.name} (${fmtSize(m.size_bytes)})`}
|
||||||
>
|
>
|
||||||
<span className="text-[9px] font-bold truncate max-w-[120px] font-space tracking-wide">
|
<span className="text-[9px] font-bold truncate max-w-[120px] font-space tracking-wide">
|
||||||
{m.role ? `[${m.role}] ` : ""}{m.name.split("/").pop()?.replace(".gguf", "")}
|
{m.role ? `[${roleMeta(m.role).short}] ` : ""}{m.name.split("/").pop()?.replace(".gguf", "")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[8px] font-mono opacity-80">{fmtSize(m.size_bytes)}</span>
|
<span className="text-[8px] font-mono opacity-80">{fmtSize(m.size_bytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,13 +321,16 @@ export function Cockpit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ZONE B.4: Lane-Routing & Policy (UI-editierbar, hot-reload) */}
|
{/* ZONE B.3: Immer-bereit-Set (Warm-Set / brains-Gruppe) — in Klartext, mit Budget */}
|
||||||
<LaneEditor />
|
<WarmSetManager />
|
||||||
|
|
||||||
|
{/* ZONE B.4: Lane-Routing & Policy (UI-editierbar, hot-reload) — Experten-Detail */}
|
||||||
|
{expert && <LaneEditor />}
|
||||||
|
|
||||||
{/* ZONE B.5: Slot-Belegung */}
|
{/* ZONE B.5: Slot-Belegung */}
|
||||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
|
||||||
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">
|
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">
|
||||||
Gateway Steckplatz-Belegung (Slot-Zuweisung)
|
{expert ? "Gateway Steckplatz-Belegung (Slot-Zuweisung)" : "Wer macht was bei Lucy — zum Ändern antippen"}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
{ROLES.map((role) => {
|
{ROLES.map((role) => {
|
||||||
@@ -332,8 +351,7 @@ export function Cockpit() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={cn("text-[9px] font-bold uppercase tracking-wider font-mono px-1.5 py-0.5 rounded border",
|
<RoleLabel role={role} />
|
||||||
roleTone(role))}>{role}</span>
|
|
||||||
{isWarm && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
|
{isWarm && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -525,22 +543,23 @@ export function Cockpit() {
|
|||||||
<Activity className="h-3 w-3 animate-pulse" /> Warm
|
<Activity className="h-3 w-3 animate-pulse" /> Warm
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{m.role && (
|
{m.role && <RoleLabel role={m.role} dense />}
|
||||||
<span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-mono text-primary font-bold uppercase">
|
{isProtected(m.role) && (
|
||||||
{m.role}
|
<span className="px-1.5 py-0.5 rounded bg-background/40 border border-border/40 text-[9px] font-semibold text-muted-foreground" title="Lebenswichtig für Lucy — geschützt vor Löschen/Entladen.">
|
||||||
|
🔒 geschützt
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isCoresident(m.name) && (
|
{expert && isCoresident(m.name) && (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[9px] font-mono text-fuchsia-300 font-bold uppercase" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm, verdrängt es nicht.">
|
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[9px] font-mono text-fuchsia-300 font-bold uppercase" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm, verdrängt es nicht.">
|
||||||
🧠 Ko-resident
|
🧠 Ko-resident
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{m.prompt_cache && (
|
{expert && m.prompt_cache && (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[9px] font-mono text-cyan-400 font-bold uppercase" title="Prompt Caching aktiv">
|
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[9px] font-mono text-cyan-400 font-bold uppercase" title="Prompt Caching aktiv">
|
||||||
PC
|
PC
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{m.spec_active ? (
|
{expert && (m.spec_active ? (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[9px] font-mono text-emerald-400 font-bold uppercase" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
|
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[9px] font-mono text-emerald-400 font-bold uppercase" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
|
||||||
SPEC
|
SPEC
|
||||||
</span>
|
</span>
|
||||||
@@ -548,8 +567,8 @@ export function Cockpit() {
|
|||||||
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-mono text-amber-400 font-bold uppercase" title={`Draft gesetzt (${m.spec_draft_model}), aber INAKTIV — --spec-type fehlt oder Vocab inkompatibel. Über 'Spec' neu konfigurieren.`}>
|
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-mono text-amber-400 font-bold uppercase" title={`Draft gesetzt (${m.spec_draft_model}), aber INAKTIV — --spec-type fehlt oder Vocab inkompatibel. Über 'Spec' neu konfigurieren.`}>
|
||||||
SPEC?
|
SPEC?
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null)}
|
||||||
{m.parallel_slots > 1 && (
|
{expert && m.parallel_slots > 1 && (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[9px] font-mono text-violet-400 font-bold uppercase" title={`${m.parallel_slots} parallele Slots aktiv`}>
|
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[9px] font-mono text-violet-400 font-bold uppercase" title={`${m.parallel_slots} parallele Slots aktiv`}>
|
||||||
SLOTS: {m.parallel_slots}
|
SLOTS: {m.parallel_slots}
|
||||||
</span>
|
</span>
|
||||||
@@ -606,28 +625,38 @@ export function Cockpit() {
|
|||||||
|
|
||||||
{/* Actions row at the bottom of Grid Card */}
|
{/* Actions row at the bottom of Grid Card */}
|
||||||
<div className="flex items-center gap-1.5 border-t border-border/20 pt-3 mt-auto">
|
<div className="flex items-center gap-1.5 border-t border-border/20 pt-3 mt-auto">
|
||||||
|
{(() => {
|
||||||
|
const prot = isProtected(m.role)
|
||||||
|
const lockUnload = isRunning && prot
|
||||||
|
const disabled = (m.incomplete && !isRunning) || lockUnload
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
|
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
|
||||||
disabled={m.incomplete && !isRunning}
|
disabled={disabled}
|
||||||
|
title={lockUnload ? `„${roleMeta(m.role).label}" bleibt geladen (geschützt).` : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 h-7 rounded-lg text-[9px] font-bold uppercase transition-all border flex items-center justify-center gap-1",
|
"flex-1 h-7 rounded-lg text-[9px] font-bold uppercase transition-all border flex items-center justify-center gap-1",
|
||||||
m.incomplete && !isRunning
|
disabled
|
||||||
? "border-border/30 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
|
? "border-border/30 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
|
||||||
: isRunning
|
: isRunning
|
||||||
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400 cursor-pointer"
|
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400 cursor-pointer"
|
||||||
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary cursor-pointer"
|
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary cursor-pointer"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isRunning ? "Entladen" : "Laden"}
|
{lockUnload ? "🔒 geladen" : isRunning ? "Entladen" : "Laden"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
)
|
||||||
onClick={() => handleSetCtx(m.name, m.ctx)}
|
})()}
|
||||||
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
|
{expert && (
|
||||||
title="Kontextlänge anpassen"
|
<button
|
||||||
>
|
onClick={() => handleSetCtx(m.name, m.ctx)}
|
||||||
Ctx
|
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
|
||||||
</button>
|
title="Kontextlänge anpassen"
|
||||||
{brainsGroup && (
|
>
|
||||||
|
Ctx
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{expert && brainsGroup && (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCoresident(m.name)}
|
onClick={() => toggleCoresident(m.name)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -643,22 +672,31 @@ export function Cockpit() {
|
|||||||
🧠
|
🧠
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{expert && (
|
||||||
onClick={() => setSpecModel(m)}
|
<button
|
||||||
className={cn(
|
onClick={() => setSpecModel(m)}
|
||||||
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-colors cursor-pointer flex items-center gap-1",
|
className={cn(
|
||||||
m.spec_active ? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
|
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-colors cursor-pointer flex items-center gap-1",
|
||||||
: m.spec_draft_model ? "border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
m.spec_active ? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
|
||||||
: "border-border/40 text-muted-foreground hover:bg-accent"
|
: m.spec_draft_model ? "border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
||||||
)}
|
: "border-border/40 text-muted-foreground hover:bg-accent"
|
||||||
title="Speculative Draft konfigurieren (Vocab-geprüft)"
|
)}
|
||||||
>
|
title="Speculative Draft konfigurieren (Vocab-geprüft)"
|
||||||
<Zap className="h-3 w-3" /> Spec
|
>
|
||||||
</button>
|
<Zap className="h-3 w-3" /> Spec
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(m.name)}
|
onClick={() => handleDelete(m.name)}
|
||||||
className="h-7 w-7 rounded-lg border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 flex items-center justify-center transition-colors cursor-pointer"
|
disabled={isProtected(m.role)}
|
||||||
aria-label="Modell löschen" title="Modell löschen"
|
className={cn(
|
||||||
|
"h-7 w-7 rounded-lg border border-border/40 flex items-center justify-center transition-colors",
|
||||||
|
isProtected(m.role)
|
||||||
|
? "text-muted-foreground/40 cursor-not-allowed"
|
||||||
|
: "text-muted-foreground hover:text-red-400 hover:bg-red-500/5 cursor-pointer"
|
||||||
|
)}
|
||||||
|
aria-label="Modell löschen"
|
||||||
|
title={isProtected(m.role) ? `„${roleMeta(m.role).label}" ist geschützt und kann nicht gelöscht werden.` : "Modell löschen"}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -705,22 +743,23 @@ export function Cockpit() {
|
|||||||
<h4 className="text-xs font-bold text-foreground truncate max-w-[250px] sm:max-w-[400px] break-all font-mono" title={m.name}>
|
<h4 className="text-xs font-bold text-foreground truncate max-w-[250px] sm:max-w-[400px] break-all font-mono" title={m.name}>
|
||||||
{m.name.split("/").pop()}
|
{m.name.split("/").pop()}
|
||||||
</h4>
|
</h4>
|
||||||
{m.role && (
|
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
|
||||||
<span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[8px] font-mono text-primary font-bold uppercase shrink-0">
|
{isProtected(m.role) && (
|
||||||
{m.role}
|
<span className="px-1.5 py-0.5 rounded bg-background/40 border border-border/40 text-[8px] font-semibold text-muted-foreground shrink-0" title="Lebenswichtig für Lucy — geschützt vor Löschen/Entladen.">
|
||||||
|
🔒 geschützt
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isCoresident(m.name) && (
|
{expert && isCoresident(m.name) && (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[8px] font-mono text-fuchsia-300 font-bold uppercase shrink-0" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm.">
|
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[8px] font-mono text-fuchsia-300 font-bold uppercase shrink-0" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm.">
|
||||||
🧠 Ko-resident
|
🧠 Ko-resident
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{m.prompt_cache && (
|
{expert && m.prompt_cache && (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[8px] font-mono text-cyan-400 font-bold uppercase shrink-0" title="Prompt Caching aktiv">
|
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[8px] font-mono text-cyan-400 font-bold uppercase shrink-0" title="Prompt Caching aktiv">
|
||||||
PC
|
PC
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{m.spec_active ? (
|
{expert && (m.spec_active ? (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[8px] font-mono text-emerald-400 font-bold uppercase shrink-0" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
|
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[8px] font-mono text-emerald-400 font-bold uppercase shrink-0" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
|
||||||
SPEC
|
SPEC
|
||||||
</span>
|
</span>
|
||||||
@@ -728,8 +767,8 @@ export function Cockpit() {
|
|||||||
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[8px] font-mono text-amber-400 font-bold uppercase shrink-0" title={`Draft gesetzt (${m.spec_draft_model}), aber INAKTIV — --spec-type fehlt oder Vocab inkompatibel.`}>
|
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[8px] font-mono text-amber-400 font-bold uppercase shrink-0" title={`Draft gesetzt (${m.spec_draft_model}), aber INAKTIV — --spec-type fehlt oder Vocab inkompatibel.`}>
|
||||||
SPEC?
|
SPEC?
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null)}
|
||||||
{m.parallel_slots > 1 && (
|
{expert && m.parallel_slots > 1 && (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[8px] font-mono text-violet-400 font-bold uppercase shrink-0" title={`${m.parallel_slots} parallele Slots aktiv`}>
|
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[8px] font-mono text-violet-400 font-bold uppercase shrink-0" title={`${m.parallel_slots} parallele Slots aktiv`}>
|
||||||
SLOTS: {m.parallel_slots}
|
SLOTS: {m.parallel_slots}
|
||||||
</span>
|
</span>
|
||||||
@@ -762,28 +801,38 @@ export function Cockpit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{(() => {
|
||||||
|
const prot = isProtected(m.role)
|
||||||
|
const lockUnload = isRunning && prot
|
||||||
|
const disabled = (m.incomplete && !isRunning) || lockUnload
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
|
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
|
||||||
disabled={m.incomplete && !isRunning}
|
disabled={disabled}
|
||||||
|
title={lockUnload ? `„${roleMeta(m.role).label}" bleibt geladen (geschützt).` : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 px-3 rounded-lg text-[9px] font-bold uppercase transition-all border flex items-center gap-1",
|
"h-7 px-3 rounded-lg text-[9px] font-bold uppercase transition-all border flex items-center gap-1",
|
||||||
m.incomplete && !isRunning
|
disabled
|
||||||
? "border-border/30 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
|
? "border-border/30 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
|
||||||
: isRunning
|
: isRunning
|
||||||
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400 cursor-pointer"
|
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400 cursor-pointer"
|
||||||
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary cursor-pointer"
|
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary cursor-pointer"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isRunning ? "Entladen" : "Laden"}
|
{lockUnload ? "🔒 geladen" : isRunning ? "Entladen" : "Laden"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
)
|
||||||
onClick={() => handleSetCtx(m.name, m.ctx)}
|
})()}
|
||||||
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-all cursor-pointer"
|
{expert && (
|
||||||
title="Kontextlänge anpassen"
|
<button
|
||||||
>
|
onClick={() => handleSetCtx(m.name, m.ctx)}
|
||||||
Ctx
|
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-all cursor-pointer"
|
||||||
</button>
|
title="Kontextlänge anpassen"
|
||||||
{brainsGroup && (
|
>
|
||||||
|
Ctx
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{expert && brainsGroup && (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCoresident(m.name)}
|
onClick={() => toggleCoresident(m.name)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -799,22 +848,31 @@ export function Cockpit() {
|
|||||||
🧠
|
🧠
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{expert && (
|
||||||
onClick={() => setSpecModel(m)}
|
<button
|
||||||
className={cn(
|
onClick={() => setSpecModel(m)}
|
||||||
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-all cursor-pointer flex items-center gap-1",
|
className={cn(
|
||||||
m.spec_active ? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
|
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-all cursor-pointer flex items-center gap-1",
|
||||||
: m.spec_draft_model ? "border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
m.spec_active ? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
|
||||||
: "border-border/40 text-muted-foreground hover:bg-accent"
|
: m.spec_draft_model ? "border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
||||||
)}
|
: "border-border/40 text-muted-foreground hover:bg-accent"
|
||||||
title="Speculative Draft konfigurieren (Vocab-geprüft)"
|
)}
|
||||||
>
|
title="Speculative Draft konfigurieren (Vocab-geprüft)"
|
||||||
<Zap className="h-3 w-3" /> Spec
|
>
|
||||||
</button>
|
<Zap className="h-3 w-3" /> Spec
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(m.name)}
|
onClick={() => handleDelete(m.name)}
|
||||||
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all cursor-pointer"
|
disabled={isProtected(m.role)}
|
||||||
aria-label="Modell löschen" title="Modell löschen"
|
className={cn(
|
||||||
|
"h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 transition-all",
|
||||||
|
isProtected(m.role)
|
||||||
|
? "text-muted-foreground/40 cursor-not-allowed"
|
||||||
|
: "text-muted-foreground hover:text-red-400 hover:bg-red-500/5 cursor-pointer"
|
||||||
|
)}
|
||||||
|
aria-label="Modell löschen"
|
||||||
|
title={isProtected(m.role) ? `„${roleMeta(m.role).label}" ist geschützt und kann nicht gelöscht werden.` : "Modell löschen"}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -839,11 +897,11 @@ export function Cockpit() {
|
|||||||
: models
|
: models
|
||||||
const assign = (name: string) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }
|
const assign = (name: string) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`Rolle ${activeRoleForAssign} konfigurieren`}>
|
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(activeRoleForAssign).label} konfigurieren`}>
|
||||||
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
|
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
|
||||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">
|
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
|
||||||
Rolle '{activeRoleForAssign}' konfigurieren
|
<RoleLabel role={activeRoleForAssign} /> festlegen
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveRoleForAssign(null)}
|
onClick={() => setActiveRoleForAssign(null)}
|
||||||
@@ -854,7 +912,8 @@ export function Cockpit() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Wähle ein Modell für die Rolle <strong className="text-foreground">{activeRoleForAssign}</strong>:
|
Wähle ein Modell für <strong className="text-foreground">{roleMeta(activeRoleForAssign).label}</strong>
|
||||||
|
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(activeRoleForAssign).desc}</span>
|
||||||
</p>
|
</p>
|
||||||
{rec?.recommended && (
|
{rec?.recommended && (
|
||||||
<button
|
<button
|
||||||
@@ -868,12 +927,19 @@ export function Cockpit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||||
<button
|
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
|
||||||
onClick={() => assign("")}
|
{isProtected(activeRoleForAssign) ? (
|
||||||
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
|
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
|
||||||
>
|
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben — wähle stattdessen ein anderes Modell.
|
||||||
<span>Zuweisung entfernen</span>
|
</div>
|
||||||
</button>
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => assign("")}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span>Zuweisung entfernen</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{ordered.map((m) => {
|
{ordered.map((m) => {
|
||||||
const r = recByName[m.name]
|
const r = recByName[m.name]
|
||||||
const isCur = m.role === activeRoleForAssign
|
const isCur = m.role === activeRoleForAssign
|
||||||
|
|||||||
Reference in New Issue
Block a user