Vierte Etappe. Der Client-Zustand hatte bisher keinen Ort: zwei handgebaute
useSyncExternalStore-Speicher, drei CustomEvent-Kanaele und ein localStorage-Griff
mitten in einem useState — verteilt ueber fuenf Dateien.
## Ein Store (Befund B-08/B-09)
app/store.ts (Zustand, 1,2 kB gzip) haelt genau vier Dinge: Bedienvorlieben
(Schiene, Expertenmodus, Palette), die Lage des Ereignisstroms und die
Meldungs-Warteschlange. Der Kopfkommentar nennt die Regel, nach der etwas dort
landen darf — und ein Test prueft sie: gespeichert werden AUSSCHLIESSLICH
Schienen- und Expertenmodus-Zustand, kein Strom, keine Meldungen, kein Geheimnis.
Der Metrik-Verlauf bleibt bewusst DRAUSSEN (lib/metricsStore.ts): Er nimmt ab P4
jede Sekunde einen Messpunkt entgegen, ein Store-Update pro Sekunde wuerde jede
abonnierende Komponente neu rendern.
Die drei CustomEvent-Kanaele sind ERSATZLOS weg — `grep -r dispatchEvent src`
liefert nichts mehr. Wer die Schublade oeffnen will, setzt `?system=`; wer springen
will, navigiert. Auch der letzte Rest von P2 (`mc-palette-open`) ist aufgeloest.
## Statusleiste (Spezifikation §4.1)
Der Zustand der Box stand bisher verstreut: die Ampel unten in der Sidebar, die
Auslastung nur im Cockpit, das aktive Modell nur im Modell-Manager. Wer in der
Konsole arbeitete, sah gar nichts.
Jetzt eine feste Leiste ueber allen Ansichten: Motor/Hirn-Ampel (klickbar zum
Agenten) · aktive Rolle + Durchsatz mit Sparkline · geteilter Speicher ALS BALKEN
(nicht als Prozent — 128 GB Unified Memory ist die Kernzahl dieser Box) · CPU/GPU
· Temperatur (ab 85 °C bernstein) · Betriebszeit · Token-Summe · Live-Anzeige.
Die Sparklines sind eigenes SVG, ~20 Zeilen. Fuer eine 56-Pixel-Linie waere eine
Diagramm-Bibliothek Verschwendung.
Dafuer neu im Backend: `uptime_s` in /api/system/status (psutil.boot_time).
Gehoert dorthin, weil die Box sich woechentlich selbst neu startet — dann ist
"laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
Liefert psutil nichts, steht dort None und die Leiste blendet das Feld aus,
statt eine Zahl zu erfinden.
## aria-live (Befund B-11) — und diesmal mit Absender
Im ganzen Frontend gab es KEIN einziges aria-live. app/shell/Meldungen.tsx ist
jetzt die eine Stelle fuer beilaeufige Rueckmeldungen; Fehler bekommen
`assertive` und bleiben stehen, alles andere `polite` und raeumt sich weg.
Wichtig: Die Region ist keine Attrappe. Gespeist wird sie von den Uebergaengen des
Ereignisstroms und vom Konsolen-Neustart (mit dem echten Grund aus ApiError.detail).
Der erste Verbindungsaufbau wird bewusst NICHT gemeldet — sonst begruesst jede
Seite den Nutzer mit "Verbindung wieder da".
## Ehrliche Anzeige statt stillem Altern (Befund B-15/B-18)
lib/events.ts fuehrt die Stromlage jetzt im Store: live · nachlauf · getrennt.
Beim Wechsel wird EINMAL invalidiert — vorher las relax() den Zustand erst beim
naechsten Refetch, nach einem Riss blieb die UI bis zu 150 s im langsamen Modus.
## Bahnbreiten-Deckel (Befund B-10)
Die Arbeitsflaeche endet bei 1600 px. Ohne Deckel zog sich das Cockpit auf einem
3440-px-Ultrawide auf ueber 3 000 px, waehrend die Wurzel-Schriftgroesse mitwuchs
und die Zeilen GROESSER statt lesbarer machte.
## Nebenbei
Der Buendel-Budget-Check aus P0 war fragil: `ls dist/assets/index-*.js | head -1`
kann den falschen treffen, weil Rollup auch kleine geteilte Module "index-*.js"
nennt (gemessen: ein 67-Byte-Chunk neben dem 374-kB-Einstieg). Er waere dann still
immer gruen gewesen. Beide Ampel-Dateien lesen den Einstieg jetzt aus index.html.
## Verifiziert im Browser
Statusleiste: alle Felder mit Live-Daten, "Laeuft seit 1 Std 10 min"
Backend abgewuergt -> Leiste springt auf "Nachlauf" UND die aria-live-Region
meldet "Verbindung zur Zentrale verloren" (polite)
Backend zurueck -> "Live", ohne Begruessungs-Meldung
Cockpit-Kachel -> /cockpit?system=logs, Schublade offen, Reiter "System-Logs"
Arbeitsflaeche -> max-width 1600 px
33/33 Tests gruen (7 neue fuer den Store) · ESLint 0 Fehler · tsc sauber ·
Einstieg 118 153 B gzip / Budget 125 000.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
367 lines
20 KiB
TypeScript
367 lines
20 KiB
TypeScript
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 { useExpertenmodus } from "@/app/store"
|
|
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()
|
|
const { data: sys } = useSystemStatus()
|
|
const { data: groupsResp } = useGroups()
|
|
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
|
|
const expert = useExpertenmodus()
|
|
|
|
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="mc-card p-4">
|
|
<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="mc-card p-5">
|
|
{!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 — bei lebenswichtigen Rollen (Hirn/Gedächtnis) gesperrt: die Rolle
|
|
wandert nur, indem man sie einem ANDEREN Modell zuweist (Backend erzwingt das auch). */}
|
|
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
|
|
<div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
<span>Rolle</span>
|
|
{protectedRole && <span className="normal-case font-medium tracking-normal text-muted-foreground/70">🔒 lebenswichtig — zum Tausch die Rolle woanders zuweisen</span>}
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<RoleChip active={!m.role} onClick={() => onRole("")} dot="bg-slate-400" label="keine" disabled={protectedRole} />
|
|
{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} disabled={protectedRole && m.role !== r.role} />
|
|
))}
|
|
</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, disabled }: {
|
|
active: boolean; onClick: () => void; dot: string; label: string; disabled?: boolean
|
|
}) {
|
|
return (
|
|
<button onClick={onClick} disabled={disabled}
|
|
title={disabled ? "Gesperrt: dieses Modell hält eine lebenswichtige Rolle." : undefined}
|
|
className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[10px] font-semibold transition-all",
|
|
disabled ? "cursor-not-allowed opacity-40 border-border/40 bg-background/20 text-muted-foreground"
|
|
: active ? "cursor-pointer border-primary/60 bg-primary/15 text-foreground"
|
|
: "cursor-pointer 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>
|
|
}
|