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:
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Zap, Bot } from "lucide-react"
|
import { Download, Trash2, Edit3, Activity, HardDrive, Check, Zap, Bot } from "lucide-react"
|
||||||
import { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api"
|
import { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api"
|
||||||
import { useModels, useGroups, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
|
import { useModels, useGroups, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
|
||||||
import { useDialog } from "@/lib/useDialog"
|
import { useDialog } from "@/lib/useDialog"
|
||||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { fmtSize, fmtCtx } from "@/lib/format"
|
import { fmtSize, fmtCtx } from "@/lib/format"
|
||||||
import { getBrandInfo, ROLES, RoleLabel, roleMeta } from "@/components/models/ModelBadges"
|
import { getBrandInfo, ROLES, RoleLabel, roleMeta } from "@/components/models/ModelBadges"
|
||||||
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
||||||
|
import { RoleAssignModal } from "./cockpit/RoleAssignModal"
|
||||||
import { LaneEditor } from "@/components/models/LaneEditor"
|
import { LaneEditor } from "@/components/models/LaneEditor"
|
||||||
import { WarmSetManager } from "@/components/models/WarmSetManager"
|
import { WarmSetManager } from "@/components/models/WarmSetManager"
|
||||||
import { useExpertMode } from "@/lib/useExpertMode"
|
import { useExpertMode } from "@/lib/useExpertMode"
|
||||||
@@ -887,94 +888,11 @@ export function Cockpit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Role Assignment Modal */}
|
{/* Role Assignment Modal */}
|
||||||
{activeRoleForAssign && (() => {
|
{activeRoleForAssign && (
|
||||||
const rec = roleRec && roleRec.role === activeRoleForAssign ? roleRec : null
|
<RoleAssignModal role={activeRoleForAssign} roleRec={roleRec} models={models}
|
||||||
const recByName: Record<string, RoleRecResp["models"][number]> = {}
|
onAssign={(name) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }}
|
||||||
rec?.models.forEach((r) => { recByName[r.name] = r })
|
onClose={() => setActiveRoleForAssign(null)} />
|
||||||
// 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
|
|
||||||
const assign = (name: string) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }
|
|
||||||
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(activeRoleForAssign).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={activeRoleForAssign} /> festlegen
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveRoleForAssign(null)}
|
|
||||||
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(activeRoleForAssign).label}</strong>
|
|
||||||
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(activeRoleForAssign).desc}</span>
|
|
||||||
</p>
|
|
||||||
{rec?.recommended && (
|
|
||||||
<button
|
|
||||||
onClick={() => assign(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(activeRoleForAssign) ? (
|
|
||||||
<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={() => assign("")}
|
|
||||||
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 === activeRoleForAssign
|
|
||||||
const isRec = !!r?.recommended
|
|
||||||
const unfit = !!r && !r.suitable
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={m.name}
|
|
||||||
onClick={() => assign(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>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{specModel && (
|
{specModel && (
|
||||||
<SpecDraftModal
|
<SpecDraftModal
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user