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(null) const [query, setQuery] = useState("") const [filter, setFilter] = useState<"all" | "in_use">("all") const [specModel, setSpecModel] = useState(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
Werkbank wird geladen …
if (error) return
Gateway oder Engine nicht erreichbar ({String(error)}).
return (
{/* Master — Liste */}
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" />
{(["all", "in_use"] as const).map((f) => ( ))}
{filtered.length === 0 ? (
Keine Modelle.
) : 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 ( ) })}
{/* Detail — verwalten */}
{!selected ? (

Links ein Modell wählen

Dann verwaltest du es hier — laden, Rolle, Kontext, löschen.

) : ( 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)} /> )}
{specModel && setSpecModel(null)} onChanged={reload} />} {dialogElement}
) } 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 (
{/* Kopf */}
{brand.initial}

{short(m.name)}

{m.role ? : keine Rolle} {protectedRole && geschützt} {coresident && Ko-resident} {warm && warm}
{/* Primär-Aktion: laden / entladen */} {/* 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). */}
Rolle {protectedRole && 🔒 lebenswichtig — zum Tausch die Rolle woanders zuweisen}
onRole("")} dot="bg-slate-400" label="keine" disabled={protectedRole} /> {ROLE_META.map((r) => ( onRole(r.role)} dot={roleBarColor(r.role).dot} label={r.short} disabled={protectedRole && m.role !== r.role} /> ))}
{/* Kennzahlen */}
{/* Beschleuniger */}
Beschleuniger {expert && ( )}
{m.spec_active && } {!m.spec_active && m.spec_draft_model && } {m.prompt_cache && } {m.parallel_slots > 1 && } {!m.spec_active && !m.spec_draft_model && !m.prompt_cache && m.parallel_slots <= 1 && ( keine aktiv )}
{/* Fähigkeiten */}
{/* Löschen */}
) } function RoleChip({ active, onClick, dot, label, disabled }: { active: boolean; onClick: () => void; dot: string; label: string; disabled?: boolean }) { return ( ) } function Metric({ icon: Icon, label, value }: { icon: typeof HardDrive; label: string; value: string }) { return (
{label}
{value}
) } 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 {label} }