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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user