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:
@@ -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"}
|
||||
|
||||
@@ -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. 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>
|
||||
)
|
||||
}
|
||||
@@ -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 3–7 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user