Frontend: Cockpit entflochten (Review P2-14, Teil 2) — 990 -> 909 Zeilen

- views/models/cockpit/RoleAssignModal.tsx: Rollen-Zuweisung inkl. Empfehlungs-Sortierung
  und Schutzgelaender als eigenstaendige Komponente
- Preview-verifiziert gegen die Live-Box: Modal oeffnet per Slot-Karte ('Lucys Hirn
  festlegen'), Schutzgelaender greift, 9 Modell-Optionen, schliesst sauber
- Zone C (Library, ~440 Z) bleibt fuer eine Folge-Session (tief mit Aktions-State verwoben)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-02 14:47:10 +02:00
parent e84386b03e
commit 588d032335
2 changed files with 111 additions and 89 deletions
@@ -0,0 +1,104 @@
import { Check, X, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
import { fmtSize } from "@/lib/format"
import { roleMeta } from "@/lib/roleMeta"
import { RoleLabel } from "@/components/models/ModelBadges"
import type { ModelInfo, RoleRecResp } from "@/lib/api"
// Rollen-Zuweisungs-Modal des Cockpits (Review P2-14, Teil 2: aus dem 990-Z-Monolithen
// extrahiert). Zeigt die Modell-Bibliothek sortiert nach Empfehlung (RoleRec-Score) und
// hält das Schutzgeländer: lebenswichtige Rollen (Hirn/Gedächtnis) können nicht leer bleiben.
const isProtected = (role?: string | null) => !!roleMeta(role).protected
export function RoleAssignModal({ role, roleRec, models, onAssign, onClose }: {
role: string
roleRec: RoleRecResp | null
models: ModelInfo[]
onAssign: (modelName: string) => void
onClose: () => void
}) {
const rec = roleRec && roleRec.role === role ? roleRec : null
const recByName: Record<string, RoleRecResp["models"][number]> = {}
rec?.models.forEach((r) => { recByName[r.name] = r })
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
const ordered = rec
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
: models
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={`${roleMeta(role).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="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<RoleLabel role={role} /> festlegen
</h3>
<button
onClick={onClose}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Wähle ein Modell für <strong className="text-foreground">{roleMeta(role).label}</strong>
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(role).desc}</span>
</p>
{rec?.recommended && (
<button
onClick={() => onAssign(rec.recommended!)}
title={recByName[rec.recommended]?.reason}
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
>
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
</button>
)}
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
{isProtected(role) ? (
<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.
</div>
) : (
<button
onClick={() => onAssign("")}
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) => {
const r = recByName[m.name]
const isCur = m.role === role
const isRec = !!r?.recommended
const unfit = !!r && !r.suitable
return (
<button
key={m.name}
onClick={() => onAssign(m.name)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
isRec ? "border-primary/50 bg-primary/10"
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
: unfit ? "border-border/20 bg-background/10 opacity-60"
: "text-foreground bg-background/20 border-border/30"
)}
>
<div className="flex flex-col text-left min-w-0">
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
</span>
</div>
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
)
})}
</div>
</div>
</div>
)
}