Frontend: Voice-Umzug in Desktop-App, MC3-Prototyp, UI-Rework-Dateien auf lucy-v2, dist konsistent

- VoiceView + components/voice + lib/voice entfernt (lebt jetzt in client/lucy-desktop)
- mc3/: Basisstation-Prototyp hinter #mc3-Weiche (non-destruktiv)
- roleMeta/useLucyHealth/useExpertMode/ExpertToggle/LucyHealthCard/WarmSetManager (UI-Rework, auf main a341d25 committet, hier nachgezogen)
- dist frisch gebaut (Asset-Stand war inkonsistent: alte geloescht, neue untracked)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-02 10:29:59 +02:00
parent 09a1c98514
commit 98360ad31f
28 changed files with 1846 additions and 6504 deletions
+4
View File
@@ -6,6 +6,7 @@ import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCar
import { ServicesCard } from "@/components/dashboard/ServicesCard"
import { VoiceLatencyCard } from "@/components/dashboard/VoiceLatencyCard"
import { ModelsCard } from "@/components/dashboard/ModelsCard"
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
function ZoneLabel({ children }: { children: React.ReactNode }) {
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
@@ -21,6 +22,9 @@ export function DashboardView() {
<p className="text-sm text-muted-foreground">Aktueller Status von System, Modellen und Agent.</p>
</div>
{/* „Geht's Lucy gut?" — Klartext-Ampel + 1-Klick-Reparatur, ganz oben. */}
<LucyHealthCard />
{/* Hero-Banner — System, Durchsatz, Modelle (Rollen + warm + Inferenz in einer Karte) */}
<div className="grid items-stretch gap-6 lg:grid-cols-3">
<SystemStatusCard />
-155
View File
@@ -1,155 +0,0 @@
import { useEffect, useRef } from "react"
import { Mic, RotateCcw, Loader2, Volume2 } from "lucide-react"
import { Avatar3D } from "@/components/voice/Avatar3D"
import { VoiceControls } from "@/components/voice/AvatarPicker"
import { useVoiceAgent } from "@/lib/voice/useVoiceAgent"
import { cn } from "@/lib/utils"
// Fester Avatar (das eine, gewählte VRM). Liegt unter frontend/public/avatar.vrm → /avatar.vrm.
const FIXED_AVATAR = "/avatar.vrm"
// **fett** als echte Fettschrift rendern (Rest als Text; Zeilenumbrüche via whitespace-pre-wrap).
function renderRich(text: string) {
return text.split(/(\*\*[^*\n]+\*\*)/g).map((p, i) => {
const m = p.match(/^\*\*([^*]+)\*\*$/)
return m ? <strong key={i} className="font-semibold text-foreground">{m[1]}</strong> : <span key={i}>{p}</span>
})
}
// Kleiner Tipp-Indikator („Hermes schreibt …").
function Dots() {
return (
<span className="inline-flex items-center gap-1 align-middle">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</span>
)
}
const STATUS_LABEL: Record<string, string> = {
idle: "Bereit — halte zum Sprechen",
listening: "Höre zu …",
transcribing: "Verstehe …",
thinking: "Hermes denkt …",
speaking: "Hermes spricht …",
error: "Fehler",
}
export function VoiceView() {
const { status, messages, error, recording, audioLevel, emotion, pressStart, pressEnd, reset } =
useVoiceAgent()
const holding = useRef(false)
const convEndRef = useRef<HTMLDivElement>(null)
// Gespräch automatisch nach unten scrollen, wenn neue Tokens/Nachrichten kommen.
useEffect(() => {
convEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" })
}, [messages])
// Push-to-talk per Leertaste (solange der Sprechen-Tab fokussiert ist und kein Eingabefeld aktiv).
useEffect(() => {
const isField = (el: EventTarget | null) =>
el instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)
const down = (e: KeyboardEvent) => {
if (e.code !== "Space" || e.repeat || holding.current || isField(e.target)) return
e.preventDefault(); holding.current = true; pressStart()
}
const up = (e: KeyboardEvent) => {
if (e.code !== "Space" || !holding.current) return
e.preventDefault(); holding.current = false; pressEnd()
}
window.addEventListener("keydown", down)
window.addEventListener("keyup", up)
return () => { window.removeEventListener("keydown", down); window.removeEventListener("keyup", up) }
}, [pressStart, pressEnd])
const speaking = status === "speaking"
const busy = status === "transcribing" || status === "thinking"
return (
<div className="flex h-full gap-5">
{/* Avatar-Bühne */}
<div className="relative flex min-w-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/30 overflow-hidden">
<div className="flex-1 min-h-0">
<Avatar3D url={FIXED_AVATAR} audioLevel={audioLevel} emotion={emotion} />
</div>
{/* Status + Push-to-talk */}
<div className="shrink-0 flex flex-col items-center gap-3 border-t border-border/40 bg-background/30 py-5 backdrop-blur-sm">
<div className={cn(
"flex items-center gap-2 text-sm",
status === "error" ? "text-red-400" : speaking ? "text-primary" : "text-muted-foreground",
)}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{speaking && <Volume2 className="h-4 w-4 animate-pulse" />}
<span>{error || STATUS_LABEL[status]}</span>
</div>
<button
onPointerDown={(e) => { e.preventDefault(); holding.current = true; pressStart() }}
onPointerUp={() => { if (holding.current) { holding.current = false; pressEnd() } }}
onPointerLeave={() => { if (holding.current) { holding.current = false; pressEnd() } }}
className={cn(
"flex h-20 w-20 items-center justify-center rounded-full border-2 transition-all select-none touch-none",
recording
? "border-red-500 bg-red-500/20 scale-110 shadow-lg shadow-red-500/30"
: "border-primary/60 bg-primary/15 hover:bg-primary/25 hover:scale-105",
)}
title="Gedrückt halten zum Sprechen (oder Leertaste halten)"
>
<Mic className={cn("h-8 w-8", recording ? "text-red-400" : "text-primary")} />
</button>
<div className="text-[11px] text-muted-foreground">
Halten zum Sprechen · <kbd className="rounded bg-muted px-1 py-0.5 font-mono">Leertaste</kbd> geht auch
</div>
</div>
</div>
{/* Seitenspalte: Einstellungen + Transcript */}
<div className="flex w-80 shrink-0 flex-col gap-4">
<div className="rounded-xl border border-border/40 bg-card/40 p-4">
<VoiceControls />
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/40">
<div className="flex items-center justify-between border-b border-border/40 px-4 py-2.5">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Gespräch</span>
<button onClick={reset} className="text-muted-foreground hover:text-foreground" title="Neues Gespräch">
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 space-y-3 overflow-y-auto p-4 scrollbar-thin">
{messages.length === 0 && (
<p className="text-xs text-muted-foreground">
Halte den Knopf (oder die Leertaste) und sprich. Hermes hört zu, denkt mit seinem
vollen Gedächtnis und seinen Werkzeugen und antwortet hörbar.
</p>
)}
{messages.map((m, i) => (
<div key={i} className={cn("flex flex-col gap-1", m.role === "user" ? "items-end" : "items-start")}>
<span className="px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{m.role === "user" ? "Du" : "Hermes"}
</span>
<div className={cn(
"max-w-[88%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm leading-relaxed",
m.role === "user"
? "rounded-br-sm bg-primary/15 text-foreground"
: "rounded-bl-sm border border-border/40 bg-background/40 text-foreground/90",
)}>
{m.text ? renderRich(m.text) : <Dots />}
</div>
</div>
))}
<div ref={convEndRef} />
</div>
</div>
</div>
</div>
)
}
+150 -84
View File
@@ -6,9 +6,11 @@ import { useDialog } from "@/lib/useDialog"
import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils"
import { fmtSize, fmtCtx } from "@/lib/format"
import { getBrandInfo, roleTone, ROLES } from "@/components/models/ModelBadges"
import { getBrandInfo, ROLES, RoleLabel, roleMeta } from "@/components/models/ModelBadges"
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
import { LaneEditor } from "@/components/models/LaneEditor"
import { WarmSetManager } from "@/components/models/WarmSetManager"
import { useExpertMode } from "@/lib/useExpertMode"
export function Cockpit() {
const qc = useQueryClient()
@@ -18,6 +20,10 @@ export function Cockpit() {
const { data: sysStatus } = useSystemStatus()
const { data: groupsResp } = useGroups()
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
const expert = useExpertMode()
// Schutzgeländer: Modelle in einer lebenswichtigen Rolle (Hirn/Gedächtnis) dürfen
// nicht gelöscht/entladen werden. Das `protected`-Flag kommt zentral aus roleMeta.
const isProtected = (role?: string | null) => !!roleMeta(role).protected
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const error = loadErr ? String(loadErr) : ""
@@ -88,6 +94,11 @@ export function Cockpit() {
}
async function handleUnloadModel(name: string) {
const m = models.find((x) => x.name === name)
if (m && isProtected(m.role)) {
showAlert("Geschützt", `${roleMeta(m.role).label}" ist lebenswichtig für Lucy und bleibt geladen. Zum Entladen erst im Experten-Modus die Rolle einem anderen Modell geben.`)
return
}
try {
await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
reload()
@@ -164,6 +175,11 @@ export function Cockpit() {
}
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}" ist lebenswichtig für Lucy und kann nicht gelöscht werden. Weise die Rolle zuerst einem anderen Modell zu.`)
return
}
showConfirm(
"Modell löschen?",
`Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`,
@@ -258,7 +274,7 @@ export function Cockpit() {
<span className="text-[10px] font-mono text-muted-foreground">
Speicher-Pool: {fmtSize(totalRunningSize)} Gewichte{gttUsed > 0 ? ` · ${fmtSize(gttUsed)} real belegt (inkl. KV)` : ""} / {fmtSize(capacity)}
</span>
{running.length > 0 && (
{expert && running.length > 0 && (
<button
onClick={handleUnloadAll}
className="h-6 px-2.5 rounded border border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10 text-amber-400 text-[9px] font-bold uppercase transition-all cursor-pointer"
@@ -295,7 +311,7 @@ export function Cockpit() {
title={`${m.name} (${fmtSize(m.size_bytes)})`}
>
<span className="text-[9px] font-bold truncate max-w-[120px] font-space tracking-wide">
{m.role ? `[${m.role}] ` : ""}{m.name.split("/").pop()?.replace(".gguf", "")}
{m.role ? `[${roleMeta(m.role).short}] ` : ""}{m.name.split("/").pop()?.replace(".gguf", "")}
</span>
<span className="text-[8px] font-mono opacity-80">{fmtSize(m.size_bytes)}</span>
</div>
@@ -305,13 +321,16 @@ export function Cockpit() {
</div>
</div>
{/* ZONE B.4: Lane-Routing & Policy (UI-editierbar, hot-reload) */}
<LaneEditor />
{/* ZONE B.3: Immer-bereit-Set (Warm-Set / brains-Gruppe) — in Klartext, mit Budget */}
<WarmSetManager />
{/* ZONE B.4: Lane-Routing & Policy (UI-editierbar, hot-reload) — Experten-Detail */}
{expert && <LaneEditor />}
{/* ZONE B.5: Slot-Belegung */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">
Gateway Steckplatz-Belegung (Slot-Zuweisung)
{expert ? "Gateway Steckplatz-Belegung (Slot-Zuweisung)" : "Wer macht was bei Lucy — zum Ändern antippen"}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{ROLES.map((role) => {
@@ -332,8 +351,7 @@ export function Cockpit() {
)}
>
<div className="flex items-center justify-between">
<span className={cn("text-[9px] font-bold uppercase tracking-wider font-mono px-1.5 py-0.5 rounded border",
roleTone(role))}>{role}</span>
<RoleLabel role={role} />
{isWarm && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
</div>
@@ -525,22 +543,23 @@ export function Cockpit() {
<Activity className="h-3 w-3 animate-pulse" /> Warm
</span>
)}
{m.role && (
<span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-mono text-primary font-bold uppercase">
{m.role}
{m.role && <RoleLabel role={m.role} dense />}
{isProtected(m.role) && (
<span className="px-1.5 py-0.5 rounded bg-background/40 border border-border/40 text-[9px] font-semibold text-muted-foreground" title="Lebenswichtig für Lucy — geschützt vor Löschen/Entladen.">
🔒 geschützt
</span>
)}
{isCoresident(m.name) && (
{expert && isCoresident(m.name) && (
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[9px] font-mono text-fuchsia-300 font-bold uppercase" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm, verdrängt es nicht.">
🧠 Ko-resident
</span>
)}
{m.prompt_cache && (
{expert && m.prompt_cache && (
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[9px] font-mono text-cyan-400 font-bold uppercase" title="Prompt Caching aktiv">
PC
</span>
)}
{m.spec_active ? (
{expert && (m.spec_active ? (
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[9px] font-mono text-emerald-400 font-bold uppercase" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
SPEC
</span>
@@ -548,8 +567,8 @@ export function Cockpit() {
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-mono text-amber-400 font-bold uppercase" title={`Draft gesetzt (${m.spec_draft_model}), aber INAKTIV — --spec-type fehlt oder Vocab inkompatibel. Über 'Spec' neu konfigurieren.`}>
SPEC?
</span>
) : null}
{m.parallel_slots > 1 && (
) : null)}
{expert && m.parallel_slots > 1 && (
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[9px] font-mono text-violet-400 font-bold uppercase" title={`${m.parallel_slots} parallele Slots aktiv`}>
SLOTS: {m.parallel_slots}
</span>
@@ -606,28 +625,38 @@ export function Cockpit() {
{/* Actions row at the bottom of Grid Card */}
<div className="flex items-center gap-1.5 border-t border-border/20 pt-3 mt-auto">
{(() => {
const prot = isProtected(m.role)
const lockUnload = isRunning && prot
const disabled = (m.incomplete && !isRunning) || lockUnload
return (
<button
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
disabled={m.incomplete && !isRunning}
disabled={disabled}
title={lockUnload ? `${roleMeta(m.role).label}" bleibt geladen (geschützt).` : undefined}
className={cn(
"flex-1 h-7 rounded-lg text-[9px] font-bold uppercase transition-all border flex items-center justify-center gap-1",
m.incomplete && !isRunning
disabled
? "border-border/30 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
: isRunning
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400 cursor-pointer"
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary cursor-pointer"
)}
>
{isRunning ? "Entladen" : "Laden"}
{lockUnload ? "🔒 geladen" : isRunning ? "Entladen" : "Laden"}
</button>
<button
onClick={() => handleSetCtx(m.name, m.ctx)}
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
title="Kontextlänge anpassen"
>
Ctx
</button>
{brainsGroup && (
)
})()}
{expert && (
<button
onClick={() => handleSetCtx(m.name, m.ctx)}
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
title="Kontextlänge anpassen"
>
Ctx
</button>
)}
{expert && brainsGroup && (
<button
onClick={() => toggleCoresident(m.name)}
className={cn(
@@ -643,22 +672,31 @@ export function Cockpit() {
🧠
</button>
)}
<button
onClick={() => setSpecModel(m)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-colors cursor-pointer flex items-center gap-1",
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"
)}
title="Speculative Draft konfigurieren (Vocab-geprüft)"
>
<Zap className="h-3 w-3" /> Spec
</button>
{expert && (
<button
onClick={() => setSpecModel(m)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-colors cursor-pointer flex items-center gap-1",
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"
)}
title="Speculative Draft konfigurieren (Vocab-geprüft)"
>
<Zap className="h-3 w-3" /> Spec
</button>
)}
<button
onClick={() => handleDelete(m.name)}
className="h-7 w-7 rounded-lg border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 flex items-center justify-center transition-colors cursor-pointer"
aria-label="Modell löschen" title="Modell löschen"
disabled={isProtected(m.role)}
className={cn(
"h-7 w-7 rounded-lg border border-border/40 flex items-center justify-center transition-colors",
isProtected(m.role)
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:text-red-400 hover:bg-red-500/5 cursor-pointer"
)}
aria-label="Modell löschen"
title={isProtected(m.role) ? `${roleMeta(m.role).label}" ist geschützt und kann nicht gelöscht werden.` : "Modell löschen"}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
@@ -705,22 +743,23 @@ export function Cockpit() {
<h4 className="text-xs font-bold text-foreground truncate max-w-[250px] sm:max-w-[400px] break-all font-mono" title={m.name}>
{m.name.split("/").pop()}
</h4>
{m.role && (
<span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[8px] font-mono text-primary font-bold uppercase shrink-0">
{m.role}
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
{isProtected(m.role) && (
<span className="px-1.5 py-0.5 rounded bg-background/40 border border-border/40 text-[8px] font-semibold text-muted-foreground shrink-0" title="Lebenswichtig für Lucy — geschützt vor Löschen/Entladen.">
🔒 geschützt
</span>
)}
{isCoresident(m.name) && (
{expert && isCoresident(m.name) && (
<span className="px-1.5 py-0.5 rounded bg-fuchsia-500/10 border border-fuchsia-500/30 text-[8px] font-mono text-fuchsia-300 font-bold uppercase shrink-0" title="Ko-resident in der brains-Gruppe — bleibt mit dem Hirn gemeinsam warm.">
🧠 Ko-resident
</span>
)}
{m.prompt_cache && (
{expert && m.prompt_cache && (
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[8px] font-mono text-cyan-400 font-bold uppercase shrink-0" title="Prompt Caching aktiv">
PC
</span>
)}
{m.spec_active ? (
{expert && (m.spec_active ? (
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[8px] font-mono text-emerald-400 font-bold uppercase shrink-0" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
SPEC
</span>
@@ -728,8 +767,8 @@ export function Cockpit() {
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[8px] font-mono text-amber-400 font-bold uppercase shrink-0" title={`Draft gesetzt (${m.spec_draft_model}), aber INAKTIV — --spec-type fehlt oder Vocab inkompatibel.`}>
SPEC?
</span>
) : null}
{m.parallel_slots > 1 && (
) : null)}
{expert && m.parallel_slots > 1 && (
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[8px] font-mono text-violet-400 font-bold uppercase shrink-0" title={`${m.parallel_slots} parallele Slots aktiv`}>
SLOTS: {m.parallel_slots}
</span>
@@ -762,28 +801,38 @@ export function Cockpit() {
</div>
<div className="flex items-center gap-1.5">
{(() => {
const prot = isProtected(m.role)
const lockUnload = isRunning && prot
const disabled = (m.incomplete && !isRunning) || lockUnload
return (
<button
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
disabled={m.incomplete && !isRunning}
disabled={disabled}
title={lockUnload ? `${roleMeta(m.role).label}" bleibt geladen (geschützt).` : undefined}
className={cn(
"h-7 px-3 rounded-lg text-[9px] font-bold uppercase transition-all border flex items-center gap-1",
m.incomplete && !isRunning
disabled
? "border-border/30 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
: isRunning
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400 cursor-pointer"
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary cursor-pointer"
)}
>
{isRunning ? "Entladen" : "Laden"}
{lockUnload ? "🔒 geladen" : isRunning ? "Entladen" : "Laden"}
</button>
<button
onClick={() => handleSetCtx(m.name, m.ctx)}
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-all cursor-pointer"
title="Kontextlänge anpassen"
>
Ctx
</button>
{brainsGroup && (
)
})()}
{expert && (
<button
onClick={() => handleSetCtx(m.name, m.ctx)}
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-all cursor-pointer"
title="Kontextlänge anpassen"
>
Ctx
</button>
)}
{expert && brainsGroup && (
<button
onClick={() => toggleCoresident(m.name)}
className={cn(
@@ -799,22 +848,31 @@ export function Cockpit() {
🧠
</button>
)}
<button
onClick={() => setSpecModel(m)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-all cursor-pointer flex items-center gap-1",
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"
)}
title="Speculative Draft konfigurieren (Vocab-geprüft)"
>
<Zap className="h-3 w-3" /> Spec
</button>
{expert && (
<button
onClick={() => setSpecModel(m)}
className={cn(
"h-7 px-2.5 rounded-lg border text-[9px] font-bold uppercase transition-all cursor-pointer flex items-center gap-1",
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"
)}
title="Speculative Draft konfigurieren (Vocab-geprüft)"
>
<Zap className="h-3 w-3" /> Spec
</button>
)}
<button
onClick={() => handleDelete(m.name)}
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all cursor-pointer"
aria-label="Modell löschen" title="Modell löschen"
disabled={isProtected(m.role)}
className={cn(
"h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 transition-all",
isProtected(m.role)
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:text-red-400 hover:bg-red-500/5 cursor-pointer"
)}
aria-label="Modell löschen"
title={isProtected(m.role) ? `${roleMeta(m.role).label}" ist geschützt und kann nicht gelöscht werden.` : "Modell löschen"}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
@@ -839,11 +897,11 @@ export function Cockpit() {
: 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={`Rolle ${activeRoleForAssign} konfigurieren`}>
<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">
Rolle '{activeRoleForAssign}' konfigurieren
<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)}
@@ -854,7 +912,8 @@ export function Cockpit() {
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Wähle ein Modell für die Rolle <strong className="text-foreground">{activeRoleForAssign}</strong>:
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
@@ -868,12 +927,19 @@ export function Cockpit() {
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
<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>
{/* 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