Merge lucy-v2: Komplett-Review 02.07.2026 (Voice-Speed, Turn-Detection, Update-Playbook, Lucy-Ausgliederung)
Konflikte zugunsten lucy-v2 aufgeloest (Superset; mains UI-Rework war dort dupliziert), dist wird nach dem Merge frisch gebaut. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+4
File diff suppressed because one or more lines are too long
+663
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-DjSNKxZa.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-liXV9csI.css">
|
||||
<script type="module" crossorigin src="/assets/index-DvUiHM4g.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-_Rap01H_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -9,13 +9,13 @@ 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
|
||||
@@ -31,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)
|
||||
@@ -60,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)
|
||||
}
|
||||
@@ -69,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 */}
|
||||
@@ -228,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>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react"
|
||||
|
||||
// Globale Error Boundary (Review F4): Ein JS-Fehler in einer View riss vorher die komplette
|
||||
// App in einen weißen Screen. Hier: Fehler anzeigen + Neu-laden-Knopf, Rest bleibt bedienbar
|
||||
// nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Three.js-Crashes.)
|
||||
interface State { error: Error | null }
|
||||
|
||||
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("Unbehandelter UI-Fehler:", error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-neutral-200 p-8">
|
||||
<div className="max-w-lg space-y-4 text-center">
|
||||
<div className="text-2xl">Da ist etwas schiefgelaufen.</div>
|
||||
<div className="text-sm text-neutral-400 break-all">
|
||||
{this.state.error.message}
|
||||
</div>
|
||||
<button
|
||||
className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText, Package, GitCommit, ExternalLink, ArrowRight, Shuffle } from "lucide-react"
|
||||
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, AlertTriangle, Camera, Bot, Box, Shuffle } from "lucide-react"
|
||||
import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CustomDialog } from "./CustomDialog"
|
||||
import { SERVICES, ServiceRow, UpdateRow, formatBytes } from "./system/rows"
|
||||
import { SettingsTab } from "./system/SettingsTab"
|
||||
import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal"
|
||||
|
||||
|
||||
interface SystemDrawerProps {
|
||||
@@ -11,58 +14,6 @@ interface SystemDrawerProps {
|
||||
defaultTab?: "maintenance" | "logs" | "settings"
|
||||
}
|
||||
|
||||
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
|
||||
const SERVICES = [
|
||||
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
|
||||
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
|
||||
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
|
||||
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
|
||||
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
|
||||
]
|
||||
|
||||
function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
|
||||
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
|
||||
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
|
||||
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-foreground truncate">{name}</div>
|
||||
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
|
||||
</div>
|
||||
<button onClick={onAction} disabled={!available || busy}
|
||||
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
|
||||
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
|
||||
{busy ? "…" : actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
|
||||
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
|
||||
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
|
||||
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
|
||||
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
|
||||
</button>
|
||||
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number) {
|
||||
if (bytes == null) return ""
|
||||
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
|
||||
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
|
||||
@@ -81,7 +32,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
const [hermesUpdating, setHermesUpdating] = useState(false)
|
||||
|
||||
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
|
||||
const [detail, setDetail] = useState<{ kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null } | null>(null)
|
||||
const [detail, setDetail] = useState<UpdateDetailState | null>(null)
|
||||
|
||||
// Custom Dialog State
|
||||
const [dialog, setDialog] = useState<{
|
||||
@@ -128,18 +79,6 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
})
|
||||
}
|
||||
|
||||
// Credentials State
|
||||
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
|
||||
const [hfTokenInput, setHfTokenInput] = useState("")
|
||||
const [showSudo, setShowSudo] = useState(false)
|
||||
const [showHf, setShowHf] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
|
||||
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && defaultTab) {
|
||||
@@ -702,223 +641,14 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sudo Passwort */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Shield className="h-4 w-4 text-amber-400" />
|
||||
Host Sudo-Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSudo ? "text" : "password"}
|
||||
value={sudoPasswordInput}
|
||||
onChange={(e) => setSudoPasswordInput(e.target.value)}
|
||||
aria-label="Host Sudo-Passwort"
|
||||
name="sudo-password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Sudo-Passwort für System-Operationen"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSudo(!showSudo)}
|
||||
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* HuggingFace Token */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Key className="h-4 w-4 text-violet-400" />
|
||||
HuggingFace API Token
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showHf ? "text" : "password"}
|
||||
value={hfTokenInput}
|
||||
onChange={(e) => setHfTokenInput(e.target.value)}
|
||||
aria-label="HuggingFace API Token"
|
||||
name="hf-token"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="hf_…"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHf(!showHf)}
|
||||
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
|
||||
localStorage.setItem("mc_hf_token", hfTokenInput);
|
||||
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
|
||||
}}
|
||||
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSudoPasswordInput("");
|
||||
setHfTokenInput("");
|
||||
localStorage.removeItem("mc_sudo_password");
|
||||
localStorage.removeItem("mc_hf_token");
|
||||
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
|
||||
}}
|
||||
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "settings" && <SettingsTab open={open} showAlert={showAlert} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail && (() => {
|
||||
const d = detail.data
|
||||
const meta = {
|
||||
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
|
||||
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
|
||||
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
|
||||
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
|
||||
}[detail.kind]
|
||||
const Icon = meta.icon
|
||||
const nothing = !d ? true
|
||||
: detail.kind === "os" ? (d.count ?? 0) === 0
|
||||
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
|
||||
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={() => setDetail(null)} />
|
||||
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
|
||||
{/* Header */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
|
||||
<h3 className="text-sm font-semibold">{meta.title}</h3>
|
||||
</div>
|
||||
<button onClick={() => setDetail(null)} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
|
||||
{detail.loading ? (
|
||||
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen…
|
||||
</div>
|
||||
) : d?.error ? (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
|
||||
) : detail.kind === "os" ? (
|
||||
(d?.count ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine Pakete zu aktualisieren — System ist aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.packages!.map((p) => (
|
||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
|
||||
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
|
||||
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : detail.kind === "engine" || detail.kind === "swap" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
|
||||
</div>
|
||||
{(d?.name || d?.latest_tag) && (
|
||||
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
|
||||
)}
|
||||
{d?.url && (
|
||||
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
|
||||
Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{d?.body && (
|
||||
<pre className="whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// hermes
|
||||
(d?.commits?.length ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine neuen Commits — Hermes-Agent ist bereits aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.commits!.map((c) => (
|
||||
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] truncate">{c.subject}</div>
|
||||
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">Vor dem Update wird automatisch ein Backup erstellt; danach startet der Hermes-Gateway neu.</p>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
|
||||
<button onClick={() => setDetail(null)} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
|
||||
Schließen
|
||||
</button>
|
||||
<button onClick={applyFromDetail} disabled={detail.loading || nothing || !!maintenanceJob}
|
||||
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
|
||||
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
|
||||
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{detail && (
|
||||
<UpdateDetailModal detail={detail} maintenanceJob={maintenanceJob}
|
||||
onClose={() => setDetail(null)} onApply={applyFromDetail} />
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<CustomDialog
|
||||
|
||||
@@ -14,7 +14,8 @@ const DOT: Record<string, string> = {
|
||||
loading: "bg-muted-foreground/40",
|
||||
}
|
||||
|
||||
export function LucyHealthCard() {
|
||||
// `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()
|
||||
@@ -49,12 +50,17 @@ export function LucyHealthCard() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: "Lucy wird geprüft …", 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: "Lucy geht's gut 💚", sub: "Alle wichtigen Fähigkeiten laufen." },
|
||||
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: "Kleinigkeit bei Lucy", sub: "Nichts Schlimmes — nur ein Hinweis." },
|
||||
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: "Lucy braucht Hilfe", sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` },
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Eye, EyeOff, Key, Shield } from "lucide-react"
|
||||
|
||||
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
|
||||
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
|
||||
// `showAlert` (gemeinsamer Dialog).
|
||||
export function SettingsTab({ open, showAlert }: {
|
||||
open: boolean
|
||||
showAlert: (title: string, message: string) => void
|
||||
}) {
|
||||
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
|
||||
const [hfTokenInput, setHfTokenInput] = useState("")
|
||||
const [showSudo, setShowSudo] = useState(false)
|
||||
const [showHf, setShowHf] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
|
||||
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sudo Passwort */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Shield className="h-4 w-4 text-amber-400" />
|
||||
Host Sudo-Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSudo ? "text" : "password"}
|
||||
value={sudoPasswordInput}
|
||||
onChange={(e) => setSudoPasswordInput(e.target.value)}
|
||||
aria-label="Host Sudo-Passwort"
|
||||
name="sudo-password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Sudo-Passwort für System-Operationen"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSudo(!showSudo)}
|
||||
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* HuggingFace Token */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Key className="h-4 w-4 text-violet-400" />
|
||||
HuggingFace API Token
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showHf ? "text" : "password"}
|
||||
value={hfTokenInput}
|
||||
onChange={(e) => setHfTokenInput(e.target.value)}
|
||||
aria-label="HuggingFace API Token"
|
||||
name="hf-token"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="hf_…"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHf(!showHf)}
|
||||
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
|
||||
localStorage.setItem("mc_hf_token", hfTokenInput);
|
||||
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
|
||||
}}
|
||||
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSudoPasswordInput("");
|
||||
setHfTokenInput("");
|
||||
localStorage.removeItem("mc_sudo_password");
|
||||
localStorage.removeItem("mc_hf_token");
|
||||
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
|
||||
}}
|
||||
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ArrowRight, Bot, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, Shuffle, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Job, UpdateDetails } from "@/lib/api"
|
||||
|
||||
export type UpdateDetailState = { kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null }
|
||||
|
||||
// Update-Detail-Fenster des SystemDrawers (Review P2-14: extrahiert): zeigt VOR dem
|
||||
// Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits).
|
||||
export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: {
|
||||
detail: UpdateDetailState
|
||||
maintenanceJob?: Job
|
||||
onClose: () => void
|
||||
onApply: () => void
|
||||
}) {
|
||||
const d = detail.data
|
||||
const meta = {
|
||||
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
|
||||
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
|
||||
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
|
||||
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
|
||||
}[detail.kind]
|
||||
const Icon = meta.icon
|
||||
const nothing = !d ? true
|
||||
: detail.kind === "os" ? (d.count ?? 0) === 0
|
||||
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
|
||||
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
|
||||
{/* Header */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
|
||||
<h3 className="text-sm font-semibold">{meta.title}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
|
||||
{detail.loading ? (
|
||||
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen…
|
||||
</div>
|
||||
) : d?.error ? (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
|
||||
) : detail.kind === "os" ? (
|
||||
(d?.count ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine Pakete zu aktualisieren — System ist aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.packages!.map((p) => (
|
||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
|
||||
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
|
||||
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : detail.kind === "engine" || detail.kind === "swap" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
|
||||
</div>
|
||||
{(d?.name || d?.latest_tag) && (
|
||||
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
|
||||
)}
|
||||
{d?.url && (
|
||||
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
|
||||
Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{d?.body && (
|
||||
<pre className="whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// hermes
|
||||
(d?.commits?.length ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine neuen Commits — Hermes-Agent ist bereits aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
{d?.summary && (
|
||||
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
|
||||
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.commits!.map((c) => (
|
||||
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] truncate">{c.subject}</div>
|
||||
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">Vor dem Update wird automatisch ein Backup erstellt; danach startet der Hermes-Gateway neu.</p>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
|
||||
<button onClick={onClose} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
|
||||
Schließen
|
||||
</button>
|
||||
<button onClick={onApply} disabled={detail.loading || nothing || !!maintenanceJob}
|
||||
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
|
||||
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
|
||||
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RefreshCw, FileText } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Präsentations-Atome des SystemDrawers (Review P2-14: aus dem 934-Z-Monolithen extrahiert).
|
||||
|
||||
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
|
||||
export const SERVICES = [
|
||||
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
|
||||
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
|
||||
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
|
||||
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
|
||||
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
|
||||
]
|
||||
|
||||
export function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
|
||||
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
|
||||
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
|
||||
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-foreground truncate">{name}</div>
|
||||
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
|
||||
</div>
|
||||
<button onClick={onAction} disabled={!available || busy}
|
||||
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
|
||||
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
|
||||
{busy ? "…" : actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
|
||||
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
|
||||
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
|
||||
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
|
||||
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
|
||||
</button>
|
||||
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatBytes(bytes?: number) {
|
||||
if (bytes == null) return ""
|
||||
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -306,6 +306,8 @@ export interface UpdatesResp {
|
||||
export interface UpdateDetails {
|
||||
kind: "os" | "engine" | "swap" | "hermes"
|
||||
error?: string
|
||||
// hermes: LLM-Zusammenfassung der anstehenden Commits (Breaking Changes zuerst)
|
||||
summary?: string
|
||||
// os
|
||||
count?: number
|
||||
packages?: { name: string; current: string; candidate: string }[]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import App from "./App"
|
||||
import { AppErrorBoundary } from "./components/AppErrorBoundary"
|
||||
import "./index.css"
|
||||
|
||||
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
||||
@@ -17,8 +18,10 @@ const queryClient = new QueryClient({
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
<AppErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</AppErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Zap, Bot } from "lucide-react"
|
||||
import { Download, Trash2, Edit3, Activity, HardDrive, Check, Zap, Bot } from "lucide-react"
|
||||
import { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api"
|
||||
import { useModels, useGroups, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"
|
||||
import { fmtSize, fmtCtx } from "@/lib/format"
|
||||
import { getBrandInfo, ROLES, RoleLabel, roleMeta } from "@/components/models/ModelBadges"
|
||||
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
||||
import { RoleAssignModal } from "./cockpit/RoleAssignModal"
|
||||
import { LaneEditor } from "@/components/models/LaneEditor"
|
||||
import { WarmSetManager } from "@/components/models/WarmSetManager"
|
||||
import { useExpertMode } from "@/lib/useExpertMode"
|
||||
@@ -887,94 +888,11 @@ export function Cockpit() {
|
||||
</div>
|
||||
|
||||
{/* Role Assignment Modal */}
|
||||
{activeRoleForAssign && (() => {
|
||||
const rec = roleRec && roleRec.role === activeRoleForAssign ? roleRec : null
|
||||
const recByName: Record<string, RoleRecResp["models"][number]> = {}
|
||||
rec?.models.forEach((r) => { recByName[r.name] = r })
|
||||
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
|
||||
const ordered = rec
|
||||
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
|
||||
: models
|
||||
const assign = (name: string) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(activeRoleForAssign).label} konfigurieren`}>
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
|
||||
<RoleLabel role={activeRoleForAssign} /> festlegen
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setActiveRoleForAssign(null)}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wähle ein Modell für <strong className="text-foreground">{roleMeta(activeRoleForAssign).label}</strong>
|
||||
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(activeRoleForAssign).desc}</span>
|
||||
</p>
|
||||
{rec?.recommended && (
|
||||
<button
|
||||
onClick={() => assign(rec.recommended!)}
|
||||
title={recByName[rec.recommended]?.reason}
|
||||
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
|
||||
{isProtected(activeRoleForAssign) ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
|
||||
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben — wähle stattdessen ein anderes Modell.
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => assign("")}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
|
||||
>
|
||||
<span>Zuweisung entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
{ordered.map((m) => {
|
||||
const r = recByName[m.name]
|
||||
const isCur = m.role === activeRoleForAssign
|
||||
const isRec = !!r?.recommended
|
||||
const unfit = !!r && !r.suitable
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => assign(m.name)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
|
||||
isRec ? "border-primary/50 bg-primary/10"
|
||||
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
|
||||
: unfit ? "border-border/20 bg-background/10 opacity-60"
|
||||
: "text-foreground bg-background/20 border-border/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-left min-w-0">
|
||||
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
|
||||
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
|
||||
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
|
||||
</span>
|
||||
</div>
|
||||
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{activeRoleForAssign && (
|
||||
<RoleAssignModal role={activeRoleForAssign} roleRec={roleRec} models={models}
|
||||
onAssign={(name) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }}
|
||||
onClose={() => setActiveRoleForAssign(null)} />
|
||||
)}
|
||||
|
||||
{specModel && (
|
||||
<SpecDraftModal
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Check, X, Zap } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtSize } from "@/lib/format"
|
||||
import { roleMeta } from "@/lib/roleMeta"
|
||||
import { RoleLabel } from "@/components/models/ModelBadges"
|
||||
import type { ModelInfo, RoleRecResp } from "@/lib/api"
|
||||
|
||||
// Rollen-Zuweisungs-Modal des Cockpits (Review P2-14, Teil 2: aus dem 990-Z-Monolithen
|
||||
// extrahiert). Zeigt die Modell-Bibliothek sortiert nach Empfehlung (RoleRec-Score) und
|
||||
// hält das Schutzgeländer: lebenswichtige Rollen (Hirn/Gedächtnis) können nicht leer bleiben.
|
||||
const isProtected = (role?: string | null) => !!roleMeta(role).protected
|
||||
export function RoleAssignModal({ role, roleRec, models, onAssign, onClose }: {
|
||||
role: string
|
||||
roleRec: RoleRecResp | null
|
||||
models: ModelInfo[]
|
||||
onAssign: (modelName: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const rec = roleRec && roleRec.role === role ? roleRec : null
|
||||
const recByName: Record<string, RoleRecResp["models"][number]> = {}
|
||||
rec?.models.forEach((r) => { recByName[r.name] = r })
|
||||
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
|
||||
const ordered = rec
|
||||
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
|
||||
: models
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(role).label} konfigurieren`}>
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
|
||||
<RoleLabel role={role} /> festlegen
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wähle ein Modell für <strong className="text-foreground">{roleMeta(role).label}</strong>
|
||||
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(role).desc}</span>
|
||||
</p>
|
||||
{rec?.recommended && (
|
||||
<button
|
||||
onClick={() => onAssign(rec.recommended!)}
|
||||
title={recByName[rec.recommended]?.reason}
|
||||
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
|
||||
{isProtected(role) ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
|
||||
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben — wähle stattdessen ein anderes Modell.
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onAssign("")}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
|
||||
>
|
||||
<span>Zuweisung entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
{ordered.map((m) => {
|
||||
const r = recByName[m.name]
|
||||
const isCur = m.role === role
|
||||
const isRec = !!r?.recommended
|
||||
const unfit = !!r && !r.suitable
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => onAssign(m.name)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
|
||||
isRec ? "border-primary/50 bg-primary/10"
|
||||
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
|
||||
: unfit ? "border-border/20 bg-background/10 opacity-60"
|
||||
: "text-foreground bg-background/20 border-border/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-left min-w-0">
|
||||
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
|
||||
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
|
||||
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
|
||||
</span>
|
||||
</div>
|
||||
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user