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:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user