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
@@ -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
}