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
+10 -3
View File
@@ -2,19 +2,20 @@ import { useEffect, useState, useCallback } from "react"
import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ExpertToggle } from "@/components/ExpertToggle"
import { DashboardView } from "@/views/DashboardView"
import { ModelsView } from "@/views/ModelsView"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { AgentView } from "@/views/AgentView"
import { TerminalView } from "@/views/TerminalView"
import { VoiceView } from "@/views/VoiceView"
import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries"
import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils"
import { Mc3App } from "@/mc3/Mc3App"
export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
@@ -30,6 +31,8 @@ export default function App() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
// MC3-Prototyp läuft nicht-destruktiv unter #mc3 (produktives MC2 bleibt Default).
const [isMc3, setIsMc3] = useState(() => window.location.hash === "#mc3")
const { data: health } = useHealth()
const { data: sysStatus } = useSystemStatus(20_000)
@@ -59,6 +62,7 @@ export default function App() {
useEffect(() => {
const onHash = () => {
setIsMc3(window.location.hash === "#mc3")
const h = window.location.hash.slice(1) as ViewId
if (NAV.some((n) => n.id === h)) setView(h)
}
@@ -68,6 +72,9 @@ export default function App() {
const active = NAV.find((n) => n.id === view)!
// Nicht-destruktiver MC3-Prototyp: alle Hooks liefen oben, hier nur die Weiche.
if (isMc3) return <Mc3App />
return (
<div className="flex h-full relative">
{/* Background Aurora Ambient Effects */}
@@ -195,6 +202,7 @@ export default function App() {
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-6 bg-card/20 backdrop-blur-sm">
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<div className="flex items-center gap-2">
<ExpertToggle />
<a
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
target="_blank"
@@ -226,9 +234,8 @@ export default function App() {
{view === "memory" && <MemoryView />}
{view === "agent" && <AgentView />}
{view === "terminal" && <TerminalView />}
{view === "voice" && <VoiceView />}
{view === "guide" && <GuideView />}
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "voice", "guide"].includes(view) && (
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
+37
View File
@@ -0,0 +1,37 @@
import { Sparkles, SlidersHorizontal } from "lucide-react"
import { useExpertMode, setExpertMode } from "@/lib/useExpertMode"
import { cn } from "@/lib/utils"
// Zwei-Zustand-Pille im Header: „Einfach" (aufgeräumt) ↔ „Experte" (alle Details).
export function ExpertToggle() {
const expert = useExpertMode()
return (
<div
className="flex items-center rounded-md border border-border/40 bg-background/40 p-0.5"
role="group"
aria-label="Ansichtsmodus"
title="Einfach zeigt nur das Wichtigste. Experte zeigt alle technischen Details."
>
<button
onClick={() => setExpertMode(false)}
aria-pressed={!expert}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
!expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
<Sparkles className="h-3.5 w-3.5" /> Einfach
</button>
<button
onClick={() => setExpertMode(true)}
aria-pressed={expert}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" /> Experte
</button>
</div>
)
}
@@ -0,0 +1,165 @@
import { useState } from "react"
import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react"
import { api } from "@/lib/api"
import { useDialog } from "@/lib/useDialog"
import { useQueryClient, qk } from "@/lib/queries"
import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth"
import { cn } from "@/lib/utils"
// Farb-/Text-Ton pro Check-Status.
const DOT: Record<string, string> = {
ok: "bg-emerald-500",
warn: "bg-amber-500",
down: "bg-red-500",
loading: "bg-muted-foreground/40",
}
// `frame` = Erzählrahmen: "lucy" (MC2-Dashboard) oder "box" (MC3-Basisstation).
export function LucyHealthCard({ frame = "lucy" }: { frame?: "lucy" | "box" } = {}) {
const { verdict, checks, memory, problems } = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const [busy, setBusy] = useState<Record<string, boolean>>({})
async function runRepair(id: string, repair: Repair) {
setBusy((b) => ({ ...b, [id]: true }))
try {
if (repair.kind === "loadModel") {
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
} else {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: repair.service }),
})
if (!r.ok) {
showAlert(
"Reparatur nicht ganz geklappt",
(r.err || "Unbekannter Fehler") +
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""),
)
}
}
// Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert.
qc.invalidateQueries({ queryKey: qk.health })
qc.invalidateQueries({ queryKey: qk.services })
qc.invalidateQueries({ queryKey: qk.models })
} catch (e: any) {
showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [id]: false }))
}
}
// Titel je Erzählrahmen (Box-Kontrollpanel vs. Lucy-Dashboard).
const T = frame === "box"
? { loading: "Box wird geprüft …", gut: "Alles okay", warn: "Kleinigkeit an der Box", problem: "Box braucht Hilfe" }
: { loading: "Lucy wird geprüft …", gut: "Lucy geht's gut 💚", warn: "Kleinigkeit bei Lucy", problem: "Lucy braucht Hilfe" }
// Banner-Optik je Gesamt-Verdikt.
const banner = {
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: T.loading, sub: "Einen Moment, ich schaue nach dem Rechten." },
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: T.gut, sub: "Alle wichtigen Fähigkeiten laufen." },
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: T.warn, sub: "Nichts Schlimmes — nur ein Hinweis." },
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: T.problem, sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` },
}[verdict]
const BannerIcon = banner.icon
const memTone = {
ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40",
}[memory.status]
const memText = {
ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground",
}[memory.status]
return (
<div className={cn("rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors", banner.ring)}>
{/* Gesamt-Verdikt */}
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border/40 bg-background/30">
<BannerIcon className={cn("h-6 w-6", banner.iconCls)} />
</div>
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight text-foreground">{banner.title}</h2>
<p className="text-xs text-muted-foreground">{banner.sub}</p>
</div>
</div>
{/* Speicher-Ampel */}
<div className="mt-4 rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" /> Speicher
</span>
<span className={cn("font-mono font-bold", memText)}>
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.totalGb.toFixed(0)} GB`}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", memTone)} style={{ width: `${memory.pct}%` }} />
</div>
<p className={cn("mt-1.5 text-[11px]", memText)}>{memory.text}</p>
</div>
{/* Fähigkeiten-Checkliste */}
<div className="mt-4 space-y-1.5">
{checks.map((c) => (
<CheckRow key={c.id} c={c} busy={!!busy[c.id]} onRepair={() => c.repair && runRepair(c.id, c.repair)} />
))}
</div>
{dialogElement}
</div>
)
}
function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) {
const Icon = c.icon
const bad = c.status === "down" || c.status === "warn"
return (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-xl border p-2.5 transition-colors",
c.status === "down" ? "border-red-500/25 bg-red-500/5"
: c.status === "warn" ? "border-amber-500/20 bg-amber-500/5"
: "border-border/30 bg-background/15",
)}
>
<div className="flex min-w-0 items-center gap-2.5">
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-foreground/80">
<Icon className="h-4 w-4" />
<span className={cn("absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[c.status], c.status === "down" && "animate-pulse")} />
</span>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-bold text-foreground">
{c.label}
{c.status === "ok" && <Check className="h-3 w-3 text-emerald-400" />}
{!c.critical && <span className="rounded bg-background/40 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-muted-foreground/60">optional</span>}
</div>
<div className="truncate text-[11px] text-muted-foreground">{c.detail}</div>
</div>
</div>
{bad && c.repair && (
<button
onClick={onRepair}
disabled={busy}
className={cn(
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-60",
c.status === "down"
? "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"
: "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20",
)}
title={c.repair.label}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
{c.repair.label}
</button>
)}
{bad && !c.repair && (
<span className="flex shrink-0 items-center gap-1 text-[10px] font-semibold text-muted-foreground" title="Keine Auto-Reparatur bekannt">
<ShieldQuestion className="h-3.5 w-3.5" /> manuell
</span>
)}
</div>
)
}
@@ -3,7 +3,7 @@ import { Boxes, Zap, Moon } from "lucide-react"
import { useModels, useTokenStats, useSystemStatus } from "@/lib/queries"
import { fmtSize, gb } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ROLES, roleTone } from "@/components/models/ModelBadges"
import { ROLES, RoleLabel } from "@/components/models/ModelBadges"
// Vereint die frühere RolesCard (Rolle→Modell) + ActiveModelsCard (warm/Inferenz/Größe)
// zu EINER Karte — dieselbe Info stand vorher doppelt auf der Zentrale.
@@ -85,9 +85,7 @@ export function ModelsCard() {
)}
>
<div className="flex items-center gap-2 min-w-0">
<span className={cn("text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded font-mono border tracking-wider shrink-0", roleTone(role))}>
{role}
</span>
<RoleLabel role={role} className="shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate font-mono text-foreground">
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
+30 -15
View File
@@ -1,24 +1,39 @@
import { type Fit } from "@/lib/api"
import { cn } from "@/lib/utils"
import { ROLES, roleTone, roleMeta } from "@/lib/roleMeta"
// Die kanonischen Serving-Rollen (identisch zu Backend sources.py/llamaswap.ROLE_IDS).
// `hermes` = Lucys Agent-Hirn (warm + ko-resident); UI-Label „Hirn".
export const ROLES = ["fast", "heavy", "coder", "vision", "hermes", "scout"]
// Klartext-Schicht: Rollen-Namen/Icons/Farben leben jetzt zentral in lib/roleMeta.ts.
// Re-Export hält bestehende Importe (Cockpit, ModelsCard, …) stabil.
export { ROLES, roleTone, roleMeta }
// Rolle → Farbton. EINE Quelle der Wahrheit für alle Rollen-Badges (Cockpit, ModelsCard,
// …). `hermes` = Agent-Hirn (taucht als geladenes Modell auf).
export const ROLE_TONE: Record<string, string> = {
fast: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25",
heavy: "bg-amber-500/15 text-amber-400 border-amber-500/25",
coder: "bg-violet-500/15 text-violet-400 border-violet-500/25",
vision: "bg-pink-500/15 text-pink-400 border-pink-500/25",
scout: "bg-teal-500/15 text-teal-400 border-teal-500/25",
hermes: "bg-indigo-500/15 text-indigo-400 border-indigo-500/25",
// Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip.
// `dense` = nur Kurzform (für enge Badges).
export function RoleLabel({
role,
dense = false,
className,
}: {
role?: string | null
dense?: boolean
className?: string
}) {
const meta = roleMeta(role)
const Icon = meta.icon
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border tracking-wide",
meta.tone,
className,
)}
title={`${meta.desc} (${meta.role})`}
>
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
{dense ? meta.short : meta.label}
</span>
)
}
export const roleTone = (role?: string | null) =>
(role && ROLE_TONE[role]) || "bg-slate-500/15 text-slate-300 border-slate-500/25"
export function FitBadge({ fit }: { fit: Fit }) {
const tone = {
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
@@ -0,0 +1,202 @@
import { useState } from "react"
import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react"
import { setGroup, type ModelInfo } from "@/lib/api"
import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { RoleLabel, roleMeta } from "@/components/models/ModelBadges"
import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils"
// „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder
// bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar,
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
export function WarmSetManager() {
const qc = useQueryClient()
const { data: modelsResp } = useModels(4_000)
const { data: groupsResp } = useGroups()
const { data: sysStatus } = useSystemStatus()
const { data: brain } = useHermesBrain()
const { showAlert, dialogElement } = useDialog()
const [picking, setPicking] = useState(false)
const [busy, setBusy] = useState(false)
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const group = groupsResp?.groups?.brains
const members = group?.members ?? []
const byName = (name: string) => models.find((m) => m.name === name)
const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name
const memberModels = members.map(byName).filter(Boolean) as ModelInfo[]
const B = 1024 ** 3
const bud = brain?.budget
const weightsGb = memberModels.reduce((a, m) => a + (m.size_bytes || 0), 0) / B
const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0)
// Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte.
const reservedGb = bud?.warm_projected_gb ?? weightsGb
const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0
const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete)
async function save(next: string[]) {
setBusy(true)
try {
await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true)
qc.invalidateQueries({ queryKey: qk.groups })
qc.invalidateQueries({ queryKey: qk.models })
qc.invalidateQueries({ queryKey: qk.hermesBrain })
} catch (e: any) {
showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`)
} finally {
setBusy(false)
}
}
function remove(name: string) {
const m = byName(name)
if (m && roleMeta(m.role).protected) {
showAlert("Geschützt", `${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`)
return
}
void save(members.filter((x) => x !== name))
}
function add(name: string) {
setPicking(false)
void save([...members, name])
}
if (!group) {
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<Header />
<p className="mt-3 text-xs text-muted-foreground">
Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist.
</p>
{dialogElement}
</div>
)
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<Header />
{/* Mitglieder */}
<div className="space-y-1.5">
{memberModels.length === 0 && (
<div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div>
)}
{memberModels.map((m) => {
const isWarm = running.includes(m.name)
const prot = roleMeta(m.role).protected
return (
<div key={m.name} className="flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<span className={cn("h-2 w-2 shrink-0 rounded-full ring-2 ring-black/40", isWarm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")}
title={isWarm ? "Gerade warm (bereit)" : "Reserviert — lädt bei Bedarf sofort"} />
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground" title={m.name}>{shortName(m.name)}</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</div>
{prot ? (
<span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span>
) : (
<button
onClick={() => remove(m.name)}
disabled={busy}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground transition-colors hover:bg-red-500/5 hover:text-red-400 cursor-pointer disabled:opacity-50"
title="Aus dem Immer-bereit-Set nehmen (lädt dann nur noch bei Bedarf)"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)
})}
</div>
{/* Hinzufügen */}
<button
onClick={() => setPicking(true)}
disabled={busy || eligible.length === 0}
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/50 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary cursor-pointer disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" /> Modell dauerhaft bereithalten
</button>
{/* Speicher-Budget */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Modellgewichte + Arbeitsspeicher (KV-Cache)">
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
</span>
<span className="font-mono font-bold text-foreground">
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", pct >= 85 ? "bg-red-500" : pct >= 65 ? "bg-amber-500" : "bg-teal-500")} style={{ width: `${pct}%` }} />
</div>
{bud && !bud.fits && (
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB, z.&nbsp;B. das große Hirn) passt das Set
nicht gleichzeitig in den Speicher. Beim Laden wird das Set kurz verdrängt und danach automatisch nachgeladen kostet ein paar Sekunden.
</span>
</p>
)}
{bud && bud.fits && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-400">
<Check className="h-3.5 w-3.5 shrink-0" /> Passt auch neben dem größten Gelegenheits-Modell nichts wird verdrängt.
</p>
)}
</div>
{/* Auswahl-Modal */}
{picking && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Modell zum Immer-bereit-Set hinzufügen">
<div className="w-full max-w-md space-y-3 rounded-2xl border border-border/80 bg-card p-6 shadow-2xl 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">Dauerhaft bereithalten</h3>
<button onClick={() => setPicking(false)} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<p className="text-xs text-muted-foreground">Wähle ein Modell, das ohne Ladezeit bereitstehen soll:</p>
<div className="max-h-60 space-y-1.5 overflow-y-auto pr-1">
{eligible.map((m) => (
<button
key={m.name}
onClick={() => add(m.name)}
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/30 bg-background/20 px-3 py-2.5 text-left transition-colors hover:bg-accent cursor-pointer"
>
<span className="flex min-w-0 items-center gap-2">
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground">{shortName(m.name)}</span>
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</button>
))}
</div>
</div>
</div>
)}
{dialogElement}
</div>
)
}
function Header() {
return (
<div className="flex items-center gap-2">
<Zap className="h-4.5 w-4.5 text-primary" />
<div>
<h2 className="text-sm font-bold uppercase tracking-wide text-foreground">Immer-bereit-Set</h2>
<p className="text-[11px] text-muted-foreground">Diese Modelle hält Lucy immer bereit sie antworten ohne Ladezeit.</p>
</div>
</div>
)
}
-193
View File
@@ -1,193 +0,0 @@
import { Canvas, useFrame } from "@react-three/fiber"
import { OrbitControls } from "@react-three/drei"
import { useEffect, useRef, useState, type MutableRefObject } from "react"
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
import { VRM, VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm"
import type { Emotion } from "@/lib/voice/sentiment"
// 3D-Avatar (VRM) mit Lippensync (Mund folgt dem TTS-Audiopegel), automatischem Blinzeln und
// stimmungsabhängiger Mimik. Liest die Live-Werte aus Mutable-Refs (kein Re-Render pro Frame).
type LevelRef = MutableRefObject<{ current: number }> // audioLevel.current.current = Pegel 0..1
type EmotionRef = MutableRefObject<Emotion>
// Ruhepose (A-Pose) als Basis — VRMs laden sonst in T-Pose (Arme waagerecht).
const REST: Record<string, [number, number, number]> = {
leftUpperArm: [0, 0, 1.2],
rightUpperArm: [0, 0, -1.2],
leftLowerArm: [0, -0.2, 0],
rightLowerArm: [0, 0.2, 0],
}
function setBone(vrm: VRM, name: string, x: number, y: number, z: number) {
const b = vrm.humanoid?.getNormalizedBoneNode(name as any)
if (b) b.rotation.set(x, y, z)
}
function applyRestPose(vrm: VRM) {
for (const [name, r] of Object.entries(REST)) setBone(vrm, name, r[0], r[1], r[2])
vrm.humanoid?.update()
}
// Lebendige Idle-Animation: prozedural (kein Animations-File). Über die Ruhepose gelegt:
// Atmung, langsames Wiegen + Gewichtsverlagerung, Umschauen (lookYaw/Pitch), Vorlehnen beim
// Sprechen (lean) und Arm-Mitbewegung. Die Augen (lookAt) hält die useFrame separat auf die Kamera.
function applyIdle(vrm: VRM, t: number, level: number, lookYaw: number, lookPitch: number, lean: number) {
const breathe = Math.sin(t * 1.7) // ~0.27 Hz Atmung
const sway = Math.sin(t * 0.45) // sanftes Wiegen
const weight = Math.sin(t * 0.32) // langsame Gewichtsverlagerung
const emphasis = Math.min(1, level * 1.4)
const nod = Math.sin(t * 1.3) * emphasis * 0.06 // Sprech-Nicken
// Rumpf — Atmung, Wiegen, Gewichtsverlagerung, Vorlehnen beim Sprechen
setBone(vrm, "hips", 0, weight * 0.045, weight * 0.03)
setBone(vrm, "spine", breathe * 0.025 + lean * 0.07, sway * 0.022, -weight * 0.03)
setBone(vrm, "chest", breathe * 0.02 + lean * 0.02, sway * 0.018, 0)
setBone(vrm, "upperChest", breathe * 0.015, 0, 0)
// Kopf/Hals — Umschauen (gedriftete Zielwinkel) + Atmung + Sprech-Nicken
setBone(vrm, "neck", lookPitch * 0.4 + nod * 0.5, lookYaw * 0.4, 0)
setBone(vrm, "head", lookPitch * 0.6 + nod * 0.5 + Math.sin(t * 0.6) * 0.015,
lookYaw * 0.6 + Math.sin(t * 0.27) * 0.025, Math.sin(t * 0.5) * 0.02)
// Arme — Ruhepose + leichtes Pendeln + Gewichtsverlagerung
const arm = Math.sin(t * 0.8) * 0.035
setBone(vrm, "leftUpperArm", 0, 0, 1.18 + arm + weight * 0.04)
setBone(vrm, "rightUpperArm", 0, 0, -1.18 - arm + weight * 0.04)
setBone(vrm, "leftLowerArm", 0, -0.18 - Math.sin(t * 0.8) * 0.03, 0)
setBone(vrm, "rightLowerArm", 0, 0.18 + Math.sin(t * 0.8) * 0.03, 0)
}
const EXPRESSIONS = ["happy", "angry", "sad", "surprised", "relaxed"] as const
const EMO_TO_EXPR: Record<Emotion, string | null> = {
neutral: null, happy: "happy", angry: "angry", sad: "sad", surprised: "surprised", relaxed: "relaxed",
}
function VrmModel({ url, audioLevel, emotion, onError }: {
url: string; audioLevel: LevelRef; emotion: EmotionRef; onError: (m: string) => void
}) {
const [vrm, setVrm] = useState<VRM | null>(null)
const smooth = useRef<Record<string, number>>({})
const blink = useRef({ t: 0, next: 3, active: 0 })
// Umschauen (gedriftete Kopf-Zielwinkel) + geglättetes Lehnen beim Sprechen.
const motion = useRef({ yaw: 0, pitch: 0, tYaw: 0, tPitch: 0, t: 0, next: 2.5, lean: 0 })
useEffect(() => {
let disposed = false
let loaded: VRM | null = null
const loader = new GLTFLoader()
loader.register((parser) => new VRMLoaderPlugin(parser))
loader.load(
url,
(gltf) => {
if (disposed) return
const v = gltf.userData.vrm as VRM | undefined
if (!v) { onError("Datei enthält kein gültiges VRM-Modell."); return }
VRMUtils.removeUnnecessaryVertices(gltf.scene)
if (v.meta?.metaVersion === "0") VRMUtils.rotateVRM0(v)
v.scene.rotation.y = Math.PI // dem Betrachter zuwenden
applyRestPose(v) // T-Pose → entspannte A-Pose (Arme unten)
loaded = v
setVrm(v)
},
undefined,
(err) => { console.error("VRM-Load-Fehler:", err); onError("Avatar konnte nicht geladen werden (CORS/URL?).") },
)
return () => {
disposed = true
if (loaded) VRMUtils.deepDispose(loaded.scene)
setVrm(null)
}
}, [url, onError])
useFrame((state, delta) => {
if (!vrm) return
const target = audioLevel.current?.current ?? 0
// Blickkontakt: Augen folgen der Kamera (schaut dich an).
if (vrm.lookAt) vrm.lookAt.target = state.camera
// Umschauen: alle paar Sekunden neues Kopf-Ziel, sanft hineinlerpen.
const m = motion.current
m.t += delta
if (m.t > m.next) {
m.tYaw = (Math.random() - 0.5) * 0.5 // ±0.25 rad Gieren
m.tPitch = (Math.random() - 0.5) * 0.24
m.t = 0
m.next = 2.5 + Math.random() * 3.5
}
m.yaw += (m.tYaw - m.yaw) * Math.min(1, delta * 1.5)
m.pitch += (m.tPitch - m.pitch) * Math.min(1, delta * 1.5)
m.lean += (Math.min(1, target * 1.6) - m.lean) * Math.min(1, delta * 3)
// Lebendige Idle-/Sprech-Bewegung (über die Ruhepose gelegt).
applyIdle(vrm, state.clock.elapsedTime, target, m.yaw, m.pitch, m.lean)
const em = vrm.expressionManager
if (em) {
// Lippensync: 'aa' folgt geglättet dem Audiopegel.
const aa = (smooth.current.aa ?? 0) * 0.4 + target * 0.6
smooth.current.aa = aa
em.setValue("aa", aa)
// Mimik: weich zur Ziel-Expression lerpen.
const want = EMO_TO_EXPR[emotion.current]
for (const name of EXPRESSIONS) {
const tv = want === name ? 0.75 : 0
const cv = smooth.current[name] ?? 0
const nv = cv + (tv - cv) * Math.min(1, delta * 4)
smooth.current[name] = nv
em.setValue(name, nv)
}
// Blinzeln: kurzer Dreieckspuls alle 37 s.
const b = blink.current
b.t += delta
if (b.active <= 0 && b.t > b.next) { b.active = 0.16; b.t = 0; b.next = 3 + Math.random() * 4 }
let blinkVal = 0
if (b.active > 0) {
b.active -= delta
const p = 1 - b.active / 0.16 // 0..1 Fortschritt
blinkVal = 1 - Math.abs(p - 0.5) * 2 // 0 → 1 → 0
}
em.setValue("blink", Math.max(0, blinkVal))
}
vrm.update(delta)
})
return vrm ? <primitive object={vrm.scene} /> : null
}
export function Avatar3D({ url, audioLevel, emotion }: {
url: string; audioLevel: LevelRef; emotion: EmotionRef
}) {
const [err, setErr] = useState<string | null>(null)
return (
<div className="relative h-full w-full">
<Canvas
camera={{ position: [0, 1.35, 1.25], fov: 30 }}
gl={{ alpha: true, antialias: true }}
style={{ background: "transparent" }}
>
<ambientLight intensity={0.85} />
<directionalLight position={[1, 2, 2]} intensity={1.1} />
<directionalLight position={[-1, 1, -1]} intensity={0.4} />
{/* key=url → bei Avatarwechsel sauber neu mounten */}
<VrmModel key={url} url={url} audioLevel={audioLevel} emotion={emotion} onError={setErr} />
<OrbitControls
target={[0, 1.3, 0]}
enablePan={false}
minDistance={0.7}
maxDistance={3}
minPolarAngle={Math.PI / 3}
maxPolarAngle={Math.PI / 1.8}
/>
</Canvas>
{err && (
<div className="absolute inset-x-0 bottom-3 mx-auto w-fit rounded-md bg-red-500/15 border border-red-500/30 px-3 py-1.5 text-xs text-red-300">
{err}
</div>
)}
</div>
)
}
@@ -1,155 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { Sparkles, Volume2, Loader2 } from "lucide-react"
// Stimm-Steuerung (Engine + Stimme + Probe). Der Avatar ist fest (kein Picker mehr).
// Engines: ElevenLabs (eigene Stimme) + Edge (gratis, nativ-deutsch). Auswahl in localStorage.
interface Voice { engine: string; id: string; label: string }
const ENGINES = ["elevenlabs", "edge"] as const
const ENGINE_LABEL: Record<string, string> = {
elevenlabs: "ElevenLabs (premium)",
edge: "Edge (natürlich · gratis)",
}
export function VoiceControls() {
const [voices, setVoices] = useState<Voice[]>([])
const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "elevenlabs")
const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "")
const [previewing, setPreviewing] = useState(false)
const [volume, setVolume] = useState(() => {
const raw = localStorage.getItem("mc_voice_volume")
if (raw === null || raw === "") return 0.6 // Number(null)===0 → sonst aus Versehen stumm
const v = Number(raw)
return Number.isNaN(v) ? 0.6 : v
})
const previewAudio = useRef<HTMLAudioElement | null>(null)
const onVolume = (v: number) => {
setVolume(v)
localStorage.setItem("mc_voice_volume", String(v))
window.dispatchEvent(new CustomEvent("mc-voice-volume", { detail: v }))
}
const saveVoice = (eng: string, v: string) => {
setEngine(eng); setVoice(v)
localStorage.setItem("mc_voice_engine", eng)
localStorage.setItem("mc_voice_voice", v)
}
// Stimmen holen. NIE auf leerer Stimme bleiben (ElevenLabs „Standardstimme" = Library-Voice, die das
// Free-Tier per API sperrt) → erste echte Stimme der aktiven Engine automatisch wählen.
useEffect(() => {
fetch("/api/voice/voices")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => {
const list: Voice[] = d.voices || []
setVoices(list)
if (!voice) {
const first = list.find((v) => v.engine === engine)
if (first) saveVoice(engine, first.id)
}
})
.catch(() => setVoices([]))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const playPreview = async () => {
if (previewing) return
setPreviewing(true)
try {
const r = await fetch("/api/voice/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Hallo! So klingt diese Stimme auf Deutsch.", engine, voice }),
})
if (!r.ok) throw new Error(`TTS ${r.status}`)
const url = URL.createObjectURL(await r.blob())
previewAudio.current?.pause()
const a = new Audio(url)
a.volume = Math.min(1, volume) // Regler auch für die Probe respektieren
previewAudio.current = a
a.onended = () => URL.revokeObjectURL(url)
await a.play()
} catch (e) {
console.error("Probe fehlgeschlagen:", e)
} finally {
setPreviewing(false)
}
}
const pickEngine = (eng: string) => {
const first = voices.find((v) => v.engine === eng)
saveVoice(eng, first?.id || "")
}
const voicesForEngine = voices.filter((v) => v.engine === engine)
const elKeyMissing = engine === "elevenlabs" && voicesForEngine.length === 0
return (
<div className="text-sm">
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" /> Stimme
</div>
{voices.length === 0 ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
Voice-Dienst nicht erreichbar Stimmen werden geladen, sobald der Sidecar läuft.
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-1.5">
{ENGINES.map((eng) => (
<button
key={eng}
onClick={() => pickEngine(eng)}
className={`rounded-md border px-2.5 py-1.5 text-xs transition-colors ${
engine === eng ? "border-primary/50 bg-primary/10 text-primary" : "border-border/40 hover:bg-accent"
}`}
>
{ENGINE_LABEL[eng]}
</button>
))}
</div>
<select
value={voice}
onChange={(e) => saveVoice(engine, e.target.value)}
className="w-full rounded-md border border-border/40 bg-background/40 px-2.5 py-2 text-xs outline-none focus:border-primary/50"
>
{voicesForEngine.map((v) => (
<option key={v.id} value={v.id}>{v.label}</option>
))}
</select>
<button
onClick={playPreview}
disabled={previewing || elKeyMissing}
className="flex w-full items-center justify-center gap-2 rounded-md border border-primary/40 bg-primary/10 px-2.5 py-2 text-xs text-primary hover:bg-primary/20 transition-colors disabled:opacity-60"
>
{previewing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
{previewing ? "Spielt …" : "Probe hören"}
</button>
{/* Lautstärke */}
<div className="flex items-center gap-2 pt-0.5">
<Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
type="range" min={0} max={1.2} step={0.05} value={volume}
onChange={(e) => onVolume(Number(e.target.value))}
className="h-1 flex-1 cursor-pointer accent-primary"
title="Lautstärke"
/>
<span className="w-9 text-right text-[11px] tabular-nums text-muted-foreground">{Math.round(volume * 100)}%</span>
</div>
{elKeyMissing && (
<p className="text-[11px] text-amber-300">
Keine ElevenLabs-Stimmen Key in <code>~/.hermes/.env</code> fehlt, oder keine eigene Stimme angelegt.
</p>
)}
{engine === "edge" && (
<p className="text-[11px] text-muted-foreground">
Edge: native deutsche Stimmen, gratis & ohne Key. Cloud (Text Microsoft).
</p>
)}
</div>
)}
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
import {
Brain,
BrainCog,
Code,
Eye,
BookMarked,
Telescope,
type LucideIcon,
} from "lucide-react"
// ── EINE Quelle der Wahrheit für ALLE Serving-Rollen ────────────────────────
// Klartext statt Kürzel: jede Rolle bekommt einen Zweck-Namen, ein Icon und
// eine Ein-Satz-Erklärung, damit die UI ohne Fachjargon verständlich ist.
// Das technische Kürzel (`role`) bleibt für den späteren Experten-Modus erhalten.
//
// Die Rollen hier spiegeln den ECHTEN Stand der Box (/api/models → role):
// hermes · heavy · coder · coder-lite · vision · embed · scout
// (Kein Phantom-`fast`: das ist nur ein Lane-Alias, keine Modell-Rolle. Lucys
// Alltags-Hirn ist die Rolle `hermes` = Qwen3.6.)
export interface RoleMeta {
role: string // technisches Kürzel (Experten-Modus)
label: string // Klartext-Zweckname
short: string // Kurzform für enge Badges
icon: LucideIcon
desc: string // Tooltip / Erklärung in einfacher Sprache
tone: string // Farbklassen fürs Badge
protected?: boolean // Hirn & Gedächtnis: lebenswichtig → nicht löschbar/entladbar (Etappe 3)
}
// Reihenfolge = Anzeige-Reihenfolge in den Slot-Übersichten (Wichtigstes zuerst).
export const ROLE_META: RoleMeta[] = [
{
role: "hermes",
label: "Lucys Hirn",
short: "Hirn",
icon: Brain,
desc: "Lucys Alltags-Verstand — denkt und redet mit dir. Immer bereit.",
tone: "bg-indigo-500/15 text-indigo-400 border-indigo-500/25",
protected: true,
},
{
role: "embed",
label: "Lucys Gedächtnis",
short: "Gedächtnis",
icon: BookMarked,
desc: "Merkt sich Wichtiges und findet es später wieder.",
tone: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
protected: true,
},
{
role: "vision",
label: "Lucys Augen",
short: "Augen",
icon: Eye,
desc: "Sieht Bilder und Screenshots und beschreibt, was drauf ist.",
tone: "bg-pink-500/15 text-pink-400 border-pink-500/25",
},
{
role: "heavy",
label: "Großes Hirn",
short: "Groß",
icon: BrainCog,
desc: "Das schwere Geschütz — wird für besonders knifflige Fragen dazugeholt.",
tone: "bg-amber-500/15 text-amber-400 border-amber-500/25",
},
{
role: "coder-lite",
label: "Programmierer (schnell)",
short: "Code schnell",
icon: Code,
desc: "Schnelles Programmieren im Alltag.",
tone: "bg-violet-500/15 text-violet-400 border-violet-500/25",
},
{
role: "coder",
label: "Programmierer (gründlich)",
short: "Code gründlich",
icon: Code,
desc: "Schreibt und prüft Code besonders sorgfältig — für schwere Coding-Aufgaben.",
tone: "bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/25",
},
{
role: "scout",
label: "Späher",
short: "Späher",
icon: Telescope,
desc: "Schneller erster Blick und grobe Einschätzung.",
tone: "bg-teal-500/15 text-teal-400 border-teal-500/25",
},
]
// Kanonische Rollen-Reihenfolge (ersetzt die alte, veraltete ROLES-Liste).
export const ROLES: string[] = ROLE_META.map((m) => m.role)
const BY_ROLE: Record<string, RoleMeta> = Object.fromEntries(
ROLE_META.map((m) => [m.role, m]),
)
// Fallback für unbekannte/neue Rollen — nie „undefined", damit die UID nicht bricht.
const UNKNOWN = (role?: string | null): RoleMeta => ({
role: role || "?",
label: role || "unbekannt",
short: role || "?",
icon: Brain,
desc: "Rolle ohne hinterlegten Klartext-Namen.",
tone: "bg-slate-500/15 text-slate-300 border-slate-500/25",
})
// Aliase, die auf eine echte Rolle zeigen (z.B. Lane-Alias `fast` = Hirn).
const ALIAS: Record<string, string> = {
fast: "hermes",
coder_lite: "coder-lite",
}
export function roleMeta(role?: string | null): RoleMeta {
if (!role) return UNKNOWN(role)
return BY_ROLE[role] || BY_ROLE[ALIAS[role]] || UNKNOWN(role)
}
// Klartext-Name einer Rolle (mit optionaler Kurzform).
export const roleLabel = (role?: string | null, short = false) =>
short ? roleMeta(role).short : roleMeta(role).label
// Farbton — bleibt kompatibel zur bisherigen roleTone()-Signatur.
export const roleTone = (role?: string | null) => roleMeta(role).tone
+34
View File
@@ -0,0 +1,34 @@
import { useSyncExternalStore } from "react"
// Globaler „Einfach/Experte"-Schalter. Einfach (Default) blendet technische
// Details (Kontext, Spec, Slots, Ko-Residenz, Lane-Routing …) aus, damit die UI
// nicht überfordert. In localStorage gemerkt; alle Consumer aktualisieren via Event.
const KEY = "mc_expert_mode"
const EVT = "mc-expert-mode-change"
const read = (): boolean => {
try {
return localStorage.getItem(KEY) === "true"
} catch {
return false
}
}
function subscribe(cb: () => void) {
window.addEventListener(EVT, cb)
window.addEventListener("storage", cb)
return () => {
window.removeEventListener(EVT, cb)
window.removeEventListener("storage", cb)
}
}
export function setExpertMode(v: boolean) {
localStorage.setItem(KEY, String(v))
window.dispatchEvent(new Event(EVT))
}
export function useExpertMode(): boolean {
return useSyncExternalStore(subscribe, read, () => false)
}
+173
View File
@@ -0,0 +1,173 @@
// „Geht's Lucy gut?" — bündelt die vielen technischen Status-Signale zu wenigen
// Lucy-Fähigkeiten in Klartext, inkl. Gesamt-Verdikt, Speicher-Ampel und einer
// konkreten 1-Klick-Reparatur pro Problem. EINE Quelle für das Status-Panel.
import { Brain, BookMarked, Plug, Bot, Mic, type LucideIcon } from "lucide-react"
import { useHealth, useServices, useModels, useSystemStatus } from "./queries"
export type CheckStatus = "ok" | "warn" | "down" | "loading"
// Reparatur-Rezept: entweder einen systemd-Dienst neu starten oder ein Modell laden.
// WICHTIG: `service` sind die ECHTEN Unit-Namen (nicht die Anzeige-Namen der
// Dienste-Karte), damit /api/maintenance/restart sie akzeptiert.
export type Repair =
| { kind: "restart"; service: string; label: string; needsSudo?: boolean }
| { kind: "loadModel"; model: string; label: string }
export interface LucyCheck {
id: string
label: string // Klartext-Name der Fähigkeit
icon: LucideIcon
status: CheckStatus
detail: string // Ein Satz: was ist gerade los
critical: boolean // zählt fürs Gesamt-Verdikt „Problem"
repair?: Repair
}
export type Verdict = "loading" | "gut" | "warn" | "problem"
export interface MemoryAmpel {
status: "ok" | "warn" | "full" | "unknown"
usedGb: number
totalGb: number
pct: number
text: string
}
export interface LucyHealth {
verdict: Verdict
checks: LucyCheck[]
memory: MemoryAmpel
problems: LucyCheck[] // kritische Down-Checks (für den Schnellzugriff im Banner)
}
export function useLucyHealth(): LucyHealth {
const { data: health } = useHealth()
const { data: svc } = useServices()
const { data: modelsResp } = useModels()
const { data: sys } = useSystemStatus()
const loaded = !!health && !!svc
const svcOk = (frag: string): boolean | undefined =>
svc?.services.find((s) => s.name.toLowerCase().includes(frag))?.ok
const engineOk = health?.engine_reachable
const brainReady = health?.brain?.ready
const brainName =
health?.brain?.model ||
modelsResp?.models.find((m) => m.role === "hermes")?.name ||
""
const checks: LucyCheck[] = []
// 1) Lucys Hirn — die wichtigste Fähigkeit. Engine läuft UND das Hirn-Modell ist geladen?
checks.push(((): LucyCheck => {
const base = { id: "brain", label: "Lucys Hirn", icon: Brain, critical: true }
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
if (engineOk === false)
return {
...base,
status: "down",
detail: "Die Motor-Maschine (Engine) läuft nicht — Lucy kann gerade gar nicht denken.",
repair: { kind: "restart", service: "llama-swap", label: "Motor neu starten", needsSudo: true },
}
if (brainReady === false)
return {
...base,
status: "down",
detail: "Lucys Hirn ist gerade eingeschlafen — einmal aufwecken.",
repair: brainName
? { kind: "loadModel", model: brainName, label: "Hirn aufwecken" }
: undefined,
}
return { ...base, status: "ok", detail: "Wach und ansprechbar." }
})())
// 2) Lucys Gedächtnis — Mem0-Dienst.
checks.push(((): LucyCheck => {
const base = { id: "memory", label: "Lucys Gedächtnis", icon: BookMarked, critical: true }
const ok = svcOk("mem0")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Merkt sich Neues und findet Altes wieder." }
: {
...base,
status: "down",
detail: "Lucy kann sich gerade nichts merken — das Gedächtnis antwortet nicht.",
repair: { kind: "restart", service: "mem0-service", label: "Gedächtnis neu starten" },
}
})())
// 3) Verbindung (Gateway) — der integrierte OpenAI-Gateway, über den Apps/IDEs Lucy erreichen.
checks.push(((): LucyCheck => {
const base = { id: "gateway", label: "Verbindung (Gateway)", icon: Plug, critical: true }
const ok = health ? health.gateway_reachable : svcOk("integriert")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Apps und IDEs können Lucy erreichen." }
: {
...base,
status: "down",
detail: "Die Verbindungs-Zentrale antwortet nicht — Apps erreichen Lucy nicht.",
repair: { kind: "restart", service: "mission-control-2", label: "Zentrale neu starten" },
}
})())
// 4) Lucys Agent (Hermes) — der Dienst, der als Lucy antwortet (Terminal/Telegram/Sprechen).
checks.push(((): LucyCheck => {
const base = { id: "agent", label: "Lucys Agent (Hermes)", icon: Bot, critical: true }
const ok = svcOk("hermes-gateway")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Lucy ist bereit zu reden und zu handeln." }
: {
...base,
status: "down",
detail: "Lucys Agent ist offline — sie reagiert gerade nicht.",
repair: { kind: "restart", service: "hermes-gateway", label: "Agent neu starten" },
}
})())
// 5) Lucys Stimme — Voice-Dienst (STT/TTS). Nicht kritisch: Chat geht auch ohne.
checks.push(((): LucyCheck => {
const base = { id: "voice", label: "Lucys Stimme", icon: Mic, critical: false }
const ok = svcOk("voice")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Lucy kann hören und sprechen." }
: {
...base,
status: "warn",
detail: "Sprechen ist gerade aus — Tippen geht weiter normal.",
repair: { kind: "restart", service: "voice-service", label: "Stimme neu starten" },
}
})())
// Speicher-Ampel aus dem echten GTT-Pool (unified RAM inkl. KV-Cache).
const total = sys?.gpu?.gtt_total || sys?.gpu?.vram_total || 0
const used = sys?.gpu?.gtt_used || 0
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0
const B = 1024 ** 3
const memory: MemoryAmpel = !total
? { status: "unknown", usedGb: 0, totalGb: 0, pct: 0, text: "Speicher-Auslastung unbekannt." }
: pct >= 90
? { status: "full", usedGb: used / B, totalGb: total / B, pct, text: "Speicher fast voll — Lucy könnte langsamer werden." }
: pct >= 72
? { status: "warn", usedGb: used / B, totalGb: total / B, pct, text: "Speicher gut gefüllt — noch okay." }
: { status: "ok", usedGb: used / B, totalGb: total / B, pct, text: "Genug Speicher frei." }
const anyLoading = checks.some((c) => c.status === "loading")
const problems = checks.filter((c) => c.critical && c.status === "down")
const warns = checks.filter((c) => c.status === "warn" || (!c.critical && c.status === "down"))
const memBad = memory.status === "full"
const verdict: Verdict = anyLoading && !problems.length
? "loading"
: problems.length
? "problem"
: warns.length || memBad
? "warn"
: "gut"
return { verdict, checks, memory, problems }
}
-103
View File
@@ -1,103 +0,0 @@
// Sequentielle Audio-Wiedergabe für die TTS-Antworten + Pegel-Messung fürs Lippensync.
//
// Die einzelnen Satz-WAVs kommen nacheinander rein (satzweise Synthese → niedrige Latenz).
// Wir spielen sie über EINEN AudioContext geordnet ab und hängen einen AnalyserNode dazwischen,
// dessen Energie pro Frame in `level.current` (0..1) landet — der 3D-Avatar liest das im
// useFrame und öffnet den Mund entsprechend. Kein Re-Render pro Frame (Mutable-Ref-Muster).
export class AudioQueue {
private ctx: AudioContext
private analyser: AnalyserNode
private gain: GainNode
private queue: ArrayBuffer[] = []
private playing = false
private raf = 0
private freq: Uint8Array<ArrayBuffer>
/** Mutable, vom Avatar pro Frame gelesen. 0 = Mund zu, 1 = weit offen. */
readonly level = { current: 0 }
onSpeaking?: (speaking: boolean) => void
/** Lautstärke 0..1.5 aus localStorage (Default 0.6 — die Stimmen waren zu laut). */
static readVolume(): number {
const raw = localStorage.getItem("mc_voice_volume")
if (raw === null || raw === "") return 0.6 // Number(null)===0 → sonst aus Versehen stumm
const v = Number(raw)
return Number.isNaN(v) ? 0.6 : Math.max(0, Math.min(1.5, v))
}
constructor() {
const Ctor = window.AudioContext || (window as any).webkitAudioContext
this.ctx = new Ctor()
this.analyser = this.ctx.createAnalyser()
this.analyser.fftSize = 256
this.analyser.smoothingTimeConstant = 0.6
// Kette: Quelle → Analyser (Lippensync liest vollen Pegel) → Gain (Lautstärke) → Ausgang.
this.gain = this.ctx.createGain()
this.gain.gain.value = AudioQueue.readVolume()
this.analyser.connect(this.gain)
this.gain.connect(this.ctx.destination)
this.freq = new Uint8Array(new ArrayBuffer(this.analyser.frequencyBinCount))
// Live-Lautstärke vom Slider.
window.addEventListener("mc-voice-volume", (e: Event) => {
const v = Number((e as CustomEvent).detail)
if (!Number.isNaN(v)) this.gain.gain.value = Math.max(0, Math.min(1.5, v))
})
}
async enqueue(buf: ArrayBuffer) {
this.queue.push(buf)
if (!this.playing) await this.playNext()
}
/** Laufende + wartende Wiedergabe verwerfen (z.B. wenn der Nutzer dazwischenredet). */
clear() {
this.queue = []
}
private async playNext(): Promise<void> {
const buf = this.queue.shift()
if (!buf) {
this.playing = false
this.stopMeter()
this.onSpeaking?.(false)
return
}
this.playing = true
this.onSpeaking?.(true)
if (this.ctx.state === "suspended") {
try { await this.ctx.resume() } catch { /* vom User-Gesture freigeschaltet */ }
}
let audioBuf: AudioBuffer
try {
audioBuf = await this.ctx.decodeAudioData(buf.slice(0))
} catch {
return this.playNext() // kaputtes Segment überspringen
}
const src = this.ctx.createBufferSource()
src.buffer = audioBuf
src.connect(this.analyser)
src.onended = () => { void this.playNext() }
src.start()
this.startMeter()
}
private startMeter() {
cancelAnimationFrame(this.raf)
const tick = () => {
this.analyser.getByteFrequencyData(this.freq)
// Sprachenergie liegt v.a. in den unteren/mittleren Bändern.
const n = Math.min(this.freq.length, 48)
let sum = 0
for (let i = 2; i < n; i++) sum += this.freq[i]
const avg = sum / (n - 2) / 255
this.level.current = Math.min(1, avg * 1.9)
this.raf = requestAnimationFrame(tick)
}
tick()
}
private stopMeter() {
cancelAnimationFrame(this.raf)
this.level.current = 0
}
}
-17
View File
@@ -1,17 +0,0 @@
// Leichtgewichtige Stimmungs-Heuristik (v1) → treibt die Avatar-Mimik.
// Bewusst simpel/regelbasiert (kein Modell): mappt deutschen Antworttext auf eine VRM-Expression.
// Spätere Stufe: echte Hermes-Emotion/Audio-Tags. Siehe Plan.
export type Emotion = "neutral" | "happy" | "angry" | "sad" | "surprised" | "relaxed"
const RULES: [Emotion, RegExp][] = [
["happy", /(super|toll|klasse|freu|cool|prima|perfekt|danke|großartig|wunderbar|gerne|haha|:\)|😊|😄|🎉)/i],
["surprised", /(wow|wirklich\?|krass|unglaublich|echt\?|tatsächlich|\?!|!\?|oha)/i],
["angry", /(fehler|kaputt|mist|verdammt|nervt|schlecht|problem|ärgerlich|leider nicht|geht nicht)/i],
["sad", /(leider|schade|traurig|tut mir leid|entschuldigung|sorry|bedauere)/i],
]
export function sentimentToEmotion(text: string): Emotion {
for (const [emo, rx] of RULES) if (rx.test(text)) return emo
return "neutral"
}
-50
View File
@@ -1,50 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react"
// Push-to-talk-Aufnahme über MediaRecorder. `start` beim Drücken (Taste/Button), `stop` beim
// Loslassen → fertiges Audio-Blob (webm/opus) geht an `onAudio`. Bewusst minimal & generisch.
export function usePushToTalk(onAudio: (blob: Blob) => void) {
const [recording, setRecording] = useState(false)
const recRef = useRef<MediaRecorder | null>(null)
const chunksRef = useRef<Blob[]>([])
const streamRef = useRef<MediaStream | null>(null)
const start = useCallback(async () => {
if (recRef.current) return
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (e) {
console.error("Mikrofon-Zugriff verweigert:", e)
return
}
streamRef.current = stream
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: "audio/webm"
const rec = new MediaRecorder(stream, { mimeType: mime })
chunksRef.current = []
rec.ondataavailable = (e) => { if (e.data.size) chunksRef.current.push(e.data) }
rec.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mime })
streamRef.current?.getTracks().forEach((t) => t.stop())
streamRef.current = null
recRef.current = null
setRecording(false)
if (blob.size > 1200) onAudio(blob) // Mini-Blobs (Versehen) ignorieren
}
rec.start()
recRef.current = rec
setRecording(true)
}, [onAudio])
const stop = useCallback(() => {
recRef.current?.stop()
}, [])
useEffect(() => () => {
recRef.current?.stop()
streamRef.current?.getTracks().forEach((t) => t.stop())
}, [])
return { recording, start, stop }
}
-237
View File
@@ -1,237 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { usePushToTalk } from "./usePushToTalk"
import { AudioQueue } from "./audio"
import { sentimentToEmotion, type Emotion } from "./sentiment"
// Orchestriert die ganze Voll-Duplex-Schleife im Browser:
// PTT-Audio → /api/voice/stt → User-Text
// → /api/voice/chat (SSE vom Hermes-Agenten, server-seitiger Verlauf via Session-Id)
// → Antwort satzweise schneiden → /api/voice/tts je Satz → AudioQueue (Abspielen + Lippensync)
// → Stimmung aus dem Antworttext → Avatar-Mimik
//
// Avatar-Anbindung ohne Re-Render: `audioLevel` (Mundöffnung) und `emotion` sind Mutable-Refs,
// die der 3D-Avatar pro Frame liest.
export type VoiceStatus = "idle" | "listening" | "transcribing" | "thinking" | "speaking" | "error"
export interface ChatMsg { role: "user" | "assistant"; text: string }
const SYSTEM_PROMPT =
"Du sprichst per Sprache mit dem Nutzer. Antworte natürlich, freundlich und KNAPP in ganzen, " +
"gut vorlesbaren Sätzen. Kein Markdown, keine Codeblöcke, keine Aufzählungszeichen, keine Emojis — " +
"reiner Fließtext, den man laut vorlesen kann. Verwende IMMER echte deutsche Umlaute (ä, ö, ü, ß) " +
"und NIEMALS Umschreibungen wie ae, oe, ue oder ss. Nenne möglichst keine langen URLs oder Codebefehle."
// Entfernt, was nicht vorgelesen werden soll (Links, Markdown, Code, Emojis), bevor der Satz ans TTS geht.
// Die Anzeige im Chat bleibt unangetastet — nur die gesprochene Fassung wird gesäubert.
function cleanForTTS(s: string): string {
return s
.replace(/```[\s\S]*?```/g, " ") // Codeblöcke
.replace(/`([^`]*)`/g, "$1") // Inline-Code
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [Text](url) → Text
.replace(/https?:\/\/\S+/gi, " ") // URLs
.replace(/www\.\S+/gi, " ")
.replace(/\b\S+@\S+\.\S+\b/g, " ") // E-Mails
.replace(/[*_#>~|`]+/g, " ") // Markdown-Zeichen
.replace(/^\s*[-•·]\s+/gm, " ") // Listen-Bullets
// Sonderzeichen aussprechbar machen (sonst liest die Stimme sie wörtlich/komisch):
.replace(/\s*&\s*/g, " und ")
.replace(/(\d)\s*%/g, "$1 Prozent").replace(/%/g, " Prozent ")
.replace(/(\d)\s*°\s*C?/g, "$1 Grad").replace(/°/g, " Grad ")
.replace(/\s*=\s*/g, " gleich ")
.replace(/\s*\/\s*/g, " ") // Schrägstriche → Pause statt „Schrägstrich"
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "") // Emojis/Symbole
.replace(/\s+/g, " ")
.trim()
}
function getSessionId(): string {
let id = localStorage.getItem("mc_voice_session")
if (!id) {
id = "voice-" + Math.random().toString(36).slice(2) + Date.now().toString(36)
localStorage.setItem("mc_voice_session", id)
}
return id
}
function readSettings() {
return {
engine: localStorage.getItem("mc_voice_engine") || "elevenlabs",
voice: localStorage.getItem("mc_voice_voice") || "",
}
}
// Zerlegt einen wachsenden Text-Stream in fertige Sätze. Gibt komplette Sätze zurück und behält
// den unvollständigen Rest. So kann das erste TTS schon starten, bevor die Antwort fertig ist.
function splitSentences(buffer: string): { sentences: string[]; rest: string } {
const sentences: string[] = []
const rx = /[^.!?…]+[.!?…]+(\s|$)/g
let last = 0
let m: RegExpExecArray | null
while ((m = rx.exec(buffer))) {
sentences.push(m[0].trim())
last = rx.lastIndex
}
return { sentences, rest: buffer.slice(last) }
}
export function useVoiceAgent() {
const [status, setStatus] = useState<VoiceStatus>("idle")
const [messages, setMessages] = useState<ChatMsg[]>([])
const [error, setError] = useState<string | null>(null)
const audioLevel = useRef({ current: 0 }) // wird gleich auf die Queue-Pegel gezeigt
const emotion = useRef<Emotion>("neutral")
const queueRef = useRef<AudioQueue | null>(null)
const sessionId = useRef<string>(getSessionId())
// AudioQueue erst bei Bedarf (nach User-Geste) erzeugen — Autoplay-Policy.
const ensureQueue = useCallback(() => {
if (!queueRef.current) {
const q = new AudioQueue()
q.onSpeaking = (sp) => setStatus((s) => (sp ? "speaking" : s === "speaking" ? "idle" : s))
queueRef.current = q
audioLevel.current = q.level // Avatar liest ab jetzt echte Pegel
}
return queueRef.current
}, [])
const handleAudio = useCallback(async (blob: Blob) => {
setError(null)
const queue = ensureQueue()
queue.clear() // evtl. laufende Antwort abbrechen (Barge-in)
// 1) STT
setStatus("transcribing")
let userText = ""
try {
const fd = new FormData()
fd.append("audio", blob, "rec.webm")
const r = await fetch("/api/voice/stt", { method: "POST", body: fd })
if (!r.ok) throw new Error(`STT ${r.status}`)
userText = (await r.json()).text?.trim() || ""
} catch (e: any) {
setStatus("error"); setError(`Spracherkennung fehlgeschlagen: ${e.message}`); return
}
if (!userText) { setStatus("idle"); return }
setMessages((m) => [...m, { role: "user", text: userText }])
// 2) Chat (SSE) → 3) satzweises TTS
setStatus("thinking")
const { engine, voice } = readSettings()
let assistant = ""
let pending = ""
setMessages((m) => [...m, { role: "assistant", text: "" }])
// TTS SERIELL abarbeiten (Satz für Satz), nicht parallel: Chatterbox ist nicht thread-safe
// (parallele Generierungen → 502) und Cloud-Engines mögen keine gleichzeitigen Calls. Hält
// außerdem die Reihenfolge. Die Audio-Wiedergabe selbst puffert die AudioQueue.
let ttsChain: Promise<void> = Promise.resolve()
let spokeAny = false
let ttsFailed = false
const speak = (sentence: string) => {
const spoken = cleanForTTS(sentence)
if (!spoken) return // z.B. ein Satz, der nur aus einer URL bestand
ttsChain = ttsChain.then(async () => {
try {
const r = await fetch("/api/voice/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: spoken, engine, voice }),
})
if (!r.ok) throw new Error(`TTS ${r.status}`)
await queue.enqueue(await r.arrayBuffer())
spokeAny = true
} catch (e) {
ttsFailed = true
console.error("TTS-Fehler:", e)
}
})
}
try {
const r = await fetch("/api/voice/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: userText,
session_id: sessionId.current,
system: SYSTEM_PROMPT,
}),
})
if (!r.ok || !r.body) throw new Error(`Agent ${r.status}`)
const reader = r.body.getReader()
const dec = new TextDecoder()
let sse = ""
for (;;) {
const { done, value } = await reader.read()
if (done) break
sse += dec.decode(value, { stream: true })
const events = sse.split("\n\n")
sse = events.pop() || ""
for (const ev of events) {
const line = ev.split("\n").find((l) => l.startsWith("data:"))
if (!line) continue
const data = line.slice(5).trim()
if (data === "[DONE]") continue
let json: any
try { json = JSON.parse(data) } catch { continue }
if (json.error) throw new Error(json.error)
const delta = json.choices?.[0]?.delta?.content || ""
if (!delta) continue
assistant += delta
pending += delta
emotion.current = sentimentToEmotion(assistant)
setMessages((m) => {
const copy = m.slice()
copy[copy.length - 1] = { role: "assistant", text: assistant }
return copy
})
const { sentences, rest } = splitSentences(pending)
pending = rest
sentences.forEach(speak)
}
}
if (pending.trim()) speak(pending) // Rest (letzter Satz ohne Satzzeichen)
await ttsChain // auf alle (seriellen) TTS-Calls warten
if (!assistant.trim()) { setStatus("idle"); return }
// Nichts abgespielt → nicht ewig bei „denkt" hängen, Fehler zeigen.
if (!spokeAny) {
setStatus("error")
setError(ttsFailed ? "Sprachausgabe fehlgeschlagen — Engine/Stimme prüfen." : "Keine Sprachausgabe erzeugt.")
}
} catch (e: any) {
setStatus("error"); setError(`Agent-Antwort fehlgeschlagen: ${e.message}`)
}
}, [ensureQueue])
const { recording, start, stop } = usePushToTalk(handleAudio)
const pressStart = useCallback(() => {
ensureQueue()
setStatus("listening")
void start()
}, [ensureQueue, start])
const pressEnd = useCallback(() => { stop() }, [stop])
const reset = useCallback(() => {
queueRef.current?.clear()
setMessages([])
setError(null)
setStatus("idle")
localStorage.removeItem("mc_voice_session")
sessionId.current = getSessionId()
}, [])
// Leerlauf-Status zurücksetzen, wenn nichts mehr spricht/aufnimmt.
useEffect(() => {
if (!recording && (status === "listening")) setStatus("transcribing")
}, [recording, status])
return {
status, messages, error, recording,
audioLevel, emotion,
pressStart, pressEnd, reset,
}
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react"
import { Activity, Code, Bookmark, Wrench, ArrowLeft, type LucideIcon } from "lucide-react"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { Mc3Start } from "./Mc3Start"
import { Mc3Maschinenraum } from "./Mc3Maschinenraum"
// MC3-Prototyp-Schale. Läuft nicht-destruktiv unter #mc3, das produktive MC2 bleibt
// unangetastet. Vier Türen; Innereien sind bewusst die bestehenden, bewährten Views.
type Door = "start" | "vibe" | "konstitution" | "maschinenraum"
const DOORS: { id: Door; label: string; icon: LucideIcon }[] = [
{ id: "start", label: "Start", icon: Activity },
{ id: "vibe", label: "Vibe-Coding", icon: Code },
{ id: "konstitution", label: "Konstitution", icon: Bookmark },
{ id: "maschinenraum", label: "Maschinenraum", icon: Wrench },
]
export function Mc3App() {
const [door, setDoor] = useState<Door>("start")
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
const { data: health } = useHealth()
useEffect(() => {
const onOpen = (e: Event) => {
setDrawerTab(((e as CustomEvent).detail?.tab as "maintenance" | "logs") || "maintenance")
setDrawerOpen(true)
}
window.addEventListener("open-system-drawer", onOpen)
return () => window.removeEventListener("open-system-drawer", onOpen)
}, [])
const boxOk = health && health.engine_reachable && (health.brain ? health.brain.ready : true)
return (
<div className="flex h-full relative">
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(224,30%,6%)]">
<div className="absolute inset-0 opacity-30 animate-aurora bg-gradient-to-tr from-teal-500/15 via-indigo-500/10 to-purple-500/15 blur-[130px]" />
</div>
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar — 4 Türen */}
<aside className="flex w-56 shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border/40">
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20" />
<div className="leading-tight">
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
<div className="text-[10px] text-muted-foreground">3 · Prototyp</div>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{DOORS.map((d) => (
<button key={d.id} onClick={() => setDoor(d.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-all cursor-pointer",
door === d.id ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}>
<d.icon className="h-4.5 w-4.5 shrink-0" /> {d.label}
</button>
))}
</nav>
<div className="px-3 py-3 border-t border-border/40 space-y-2">
<div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
<span className={cn("h-2 w-2 rounded-full", boxOk ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
{boxOk ? "Box läuft" : "prüfen"}
</div>
<a href="#dashboard"
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-3.5 w-3.5" /> zurück zu MC2
</a>
</div>
</aside>
{/* Main */}
<main className="flex-1 min-w-0 overflow-y-auto p-6 scrollbar-thin">
{door === "start" && <Mc3Start />}
{door === "vibe" && <ConnectView />}
{door === "konstitution" && <MemoryView />}
{door === "maschinenraum" && <Mc3Maschinenraum />}
</main>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from "react"
import { Cpu, ShieldCheck, Bot, ChevronRight, ArrowLeft } from "lucide-react"
import { Cockpit } from "@/views/models/Cockpit"
import { AgentView } from "@/views/AgentView"
import { ExpertToggle } from "@/components/ExpertToggle"
// Maschinenraum = die eine Tür für alles Technische. Faltet Modelle/Wartung/Hermes
// zusammen; jede Sektion rendert die BESTEHENDE, bewährte View wieder.
function SectionCard({ icon: Icon, title, sub, onClick }: {
icon: any; title: string; sub: string; onClick: () => void
}) {
return (
<button onClick={onClick}
className="w-full flex items-center gap-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 text-left transition-all hover:border-primary/40 cursor-pointer">
<Icon className="h-6 w-6 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground">{title}</div>
<div className="text-xs text-muted-foreground">{sub}</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground shrink-0" />
</button>
)
}
export function Mc3Maschinenraum() {
const [section, setSection] = useState<null | "modelle" | "hermes">(null)
if (section) {
return (
<div className="space-y-4">
<button onClick={() => setSection(null)}
className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<ArrowLeft className="h-4 w-4" /> Maschinenraum
</button>
{section === "modelle" ? <Cockpit /> : <AgentView />}
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Maschinenraum
</h1>
<p className="text-sm text-muted-foreground">Nur nötig, wenn du wirklich was ändern willst.</p>
</div>
<ExpertToggle />
</div>
<SectionCard icon={Cpu} title="Modelle" sub="Hirne, Coder, Immer-bereit-Set, Bibliothek"
onClick={() => setSection("modelle")} />
<SectionCard icon={ShieldCheck} title="Wartung und Logs" sub="Updates, Backup, Dienste, Logs, Zugangsdaten"
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))} />
<SectionCard icon={Bot} title="Hermes und Verdrahtung" sub="Agent, Gehirn-Modell, PC-Verbindung"
onClick={() => setSection("hermes")} />
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { ArrowUpCircle, ChevronRight, Check, Cpu, Code } from "lucide-react"
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { VoiceLatencyCard } from "@/components/dashboard/VoiceLatencyCard"
import { useUpdates, useModels } from "@/lib/queries"
import { cn } from "@/lib/utils"
// MC3-Startseite = der eine „Basisstation"-Blick: Ampel (wiederverwendet) + Updates
// (klickbar) + was gerade läuft + die geschätzte Telemetrie (System/Token/Sprach-Latenz).
// Bewusst KEINE Wärme/Platte/Speicher-Kacheln — die stehen schon im System-Status.
export function Mc3Start() {
const { data: updates } = useUpdates(30_000)
const { data: modelsResp } = useModels()
const upCount = (updates?.os || 0) + (updates?.engine || 0) + (updates?.swap || 0) + (updates?.models || 0)
const openUpdates = () => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const brain = models.find((m) => m.role === "hermes")
const coder = models.find((m) => m.role === "coder-lite") || models.find((m) => m.role === "coder")
const isWarm = (name?: string) => (name ? running.includes(name) : false)
const shortName = (n?: string) => n?.split("/").pop()?.replace(/\.gguf$/i, "") || "—"
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Basisstation
</h1>
<p className="text-sm text-muted-foreground">Geht's der Box gut, gibt's Updates, was läuft gerade.</p>
</div>
{/* Gesamt-Ampel + Fähigkeiten + Speicher (voll wiederverwendet, Box-Rahmen) */}
<LucyHealthCard frame="box" />
{/* Updates — klickbar, führt direkt zur Liste, was aktualisiert wird */}
<button
onClick={openUpdates}
className={cn(
"w-full flex items-center gap-3 rounded-2xl border p-4 text-left transition-all cursor-pointer",
upCount > 0
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10"
: "border-border/60 bg-card/45 hover:border-primary/40",
)}
>
{upCount > 0
? <ArrowUpCircle className="h-6 w-6 shrink-0 text-amber-400" />
: <Check className="h-6 w-6 shrink-0 text-emerald-400" />}
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-semibold", upCount > 0 ? "text-amber-400" : "text-foreground")}>
{upCount > 0 ? `${upCount} Update${upCount === 1 ? "" : "s"} verfügbar` : "Alles aktuell"}
</div>
<div className="text-xs text-muted-foreground">
{upCount > 0 ? "Antippen, um zu sehen, was genau aktualisiert wird." : "Keine ausstehenden Updates."}
</div>
</div>
{upCount > 0 && (
<span className="shrink-0 flex items-center gap-1 text-xs font-semibold text-amber-400">
Ansehen <ChevronRight className="h-4 w-4" />
</span>
)}
</button>
{/* Was gerade läuft — beide Lanes */}
<div>
<div className="text-xs text-muted-foreground mb-2">Was gerade läuft</div>
<div className="rounded-2xl border border-border/60 overflow-hidden">
{[
{ icon: Cpu, label: "Lucys Hirn", model: brain?.name, warm: isWarm(brain?.name) },
{ icon: Code, label: "Coder (Vibe-Coding)", model: coder?.name, warm: isWarm(coder?.name) },
].map((r) => (
<div key={r.label} className="flex items-center gap-3 px-4 py-3 border-b border-border/40 last:border-0">
<span className={cn("h-2 w-2 shrink-0 rounded-full", r.warm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")} />
<r.icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground w-40 shrink-0">{r.label}</span>
<span className="text-xs text-muted-foreground flex-1 truncate font-mono">{shortName(r.model)}</span>
<span className={cn("text-xs shrink-0", r.warm ? "text-emerald-400" : "text-muted-foreground")}>
{r.model ? (r.warm ? "warm" : "bereit") : "nicht gesetzt"}
</span>
</div>
))}
</div>
</div>
{/* Geschätzte Telemetrie — 1:1 aus MC2 übernommen */}
<div className="grid gap-6 lg:grid-cols-2">
<SystemStatusCard />
<TokenPerformanceCard />
</div>
<VoiceLatencyCard />
</div>
)
}
+1 -3
View File
@@ -6,11 +6,10 @@ import {
Bot,
TerminalSquare,
HelpCircle,
Mic,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "voice" | "guide"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "guide"
export interface NavItem {
id: ViewId
@@ -27,7 +26,6 @@ export const NAV: NavItem[] = [
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot },
{ id: "terminal", label: "Terminal", hint: "Interaktives Hermes-Agent-Terminal", icon: TerminalSquare },
{ id: "voice", label: "Sprechen", hint: "Mit Hermes per Sprache reden (3D-Avatar)", icon: Mic },
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle },
]
+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