588d032335
- 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>
105 lines
5.4 KiB
TypeScript
105 lines
5.4 KiB
TypeScript
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>
|
|
)
|
|
}
|