umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben

Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy
mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt.

- Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem
  Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der
  Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten.
- Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege,
  Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt
  Zurueckspielen mit Rueckfrage.
- Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt
  (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen.
- Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link.
- Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3,
  shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/
  JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk.
- Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf.
- 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-23 21:39:07 +02:00
co-authored by Claude Opus 5.5
parent 3d2c9549fb
commit 907289d7dc
223 changed files with 8572 additions and 15393 deletions
-382
View File
@@ -1,382 +0,0 @@
import { useEffect, useState, useCallback, Suspense, lazy } from "react"
import { Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router"
import { Command as CommandIcon, ChevronLeft, ChevronRight, Loader2, Menu, X } from "lucide-react"
import { NAV, navFuerPfad } from "@/nav"
import { ExpertToggle } from "@/components/ExpertToggle"
import { useHealth, useSystemStatus } from "@/lib/queries"
import type { Health } from "@/lib/api"
import { useMetricsFeeder } from "@/lib/metricsStore"
import { useEventStream } from "@/lib/events"
import { useMC, usePaletteOffen, useSchieneEingeklappt } from "@/app/store"
import { Statusleiste } from "@/app/shell/Statusleiste"
import { Meldungen } from "@/app/shell/Meldungen"
import { cn } from "@/lib/utils"
// Beide sind beim Start UNSICHTBAR und gehoeren deshalb nicht ins Start-Buendel (P2):
// die Schublade zieht Wartung, Logs, Einstellungen und den Update-Dialog mit, die Palette
// bringt cmdk mit. Zusammen waren das rund 30 kB gzip, die niemand beim ersten Bild braucht.
// Der Router kostete P2 ungefaehr dasselbe — so bleibt das Buendel-Budget der Ampel scharf,
// statt es einfach hochzusetzen.
const SystemDrawer = lazy(() => import("@/components/SystemDrawer").then((m) => ({ default: m.SystemDrawer })))
const CommandPalette = lazy(() => import("@/components/CommandPalette").then((m) => ({ default: m.CommandPalette })))
// Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar).
function ViewLoading() {
return (
<div className="flex h-64 items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Lädt …" />
</div>
)
}
// Tastatur-Hinweis passend zum Betriebssystem (Palette hört auf Ctrl UND Cmd).
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform)
/**
* Die Shell: Schiene, Kopfzeile, System-Schublade — alles, was über JEDER Ansicht liegt.
*
* v3-Umbau P2 (28.08.2026): Hier stand vorher der Ansichts-Umschalter
* (`{view === "ideen" && <IdeenView />}`), gespeist aus einem `useState`, das am URL-Hash
* hing. Das funktionierte für Reload und Lesezeichen — aber nur auf View-Ebene. Jetzt
* rendert der Router in den `<Outlet/>`, und der Zustand DARUNTER (offene Idee, Reiter,
* Schublade) liegt in echten Suchparametern. Siehe app/router.tsx.
*/
export function Shell() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
useEventStream() // SSE-Invalidation-Bus (P3a): sofortige Updates, Poller nur Netz
const navigate = useNavigate()
const pfad = useRouterState({ select: (s) => s.location.pathname })
// Die Schublade liegt über allem und gehört deshalb in die Wurzel-Suche (?system=).
// Vorher war sie lokaler State und wurde per CustomEvent von außen aufgestoßen —
// nicht teilbar, nicht reload-fest.
const drawerTab = useRouterState({
select: (s) => (s.location.search as { system?: "wartung" | "logs" }).system,
})
// v3-Umbau P3: Schienen-Zustand und Palette liegen jetzt im Store (Befund B-08/B-09).
// Vorher: ein localStorage-Zugriff mitten in useState plus ein GEFAELSCHTES
// KeyboardEvent, das der Kopfzeilen-Knopf aufs Dokument feuerte.
const schieneEingeklappt = useSchieneEingeklappt()
const schieneUmschalten = useMC((st) => st.schieneUmschalten)
const paletteOffen = usePaletteOffen()
const palette = useMC((st) => st.palette)
const [mobileNavOpen, setMobileNavOpen] = useState(false)
// Die Palette wird beim ERSTEN Ruf geladen und bleibt dann gemountet — sie haelt ihren
// Inhalt und soll nicht bei jedem Strg+K neu nachgeladen werden.
const [paletteBereit, setPaletteBereit] = useState(false)
useEffect(() => {
if (paletteOffen) setPaletteBereit(true)
}, [paletteOffen])
useEffect(() => {
const taste = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault()
palette(!useMC.getState().ui.paletteOffen)
}
}
document.addEventListener("keydown", taste)
return () => document.removeEventListener("keydown", taste)
}, [palette])
const schubladeSetzen = useCallback((tab: "wartung" | "logs" | undefined) => {
navigate({ to: ".", search: (alt) => ({ ...alt, system: tab }), replace: true })
}, [navigate])
// isError mitnehmen: React Query behält bei Fehlern die letzten Daten — ohne den
// Fehler-Blick zeigte die Sidebar bei totem Backend ewig den letzten grünen Stand.
const { data: health, isError: backendDown } = useHealth()
useEffect(() => {
document.documentElement.classList.add("dark")
}, [])
// Hier hingen bis P2 zwei CustomEvent-Zuhörer (`open-system-drawer`, `mc-navigate`),
// über die andere Ansichten die Shell fernsteuerten. In P3 sind sie ersatzlos weg:
// Wer die Schublade öffnen will, setzt `?system=`; wer springen will, navigiert. Der
// Zustand liegt damit da, wo man ihn sehen und teilen kann (Befund B-08).
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
useEffect(() => { setMobileNavOpen(false) }, [pfad])
const active = navFuerPfad(pfad)
return (
<div className="flex h-full relative">
{/* Ruhiger Ambient-Hintergrund: statische radial-gradients statt animierter
Fullscreen-Blur-Filter — gleicher Look, praktisch keine GPU-Last. */}
<div
className="fixed inset-0 -z-50 pointer-events-none"
style={{
backgroundColor: "hsl(224,30%,6%)",
backgroundImage: [
"radial-gradient(45% 40% at 12% 8%, hsl(172 72% 50% / 0.10), transparent 70%)",
"radial-gradient(50% 45% at 88% 92%, hsl(270 70% 60% / 0.10), transparent 70%)",
"radial-gradient(40% 35% at 75% 30%, hsl(239 70% 62% / 0.07), transparent 70%)",
].join(", "),
}}
/>
{/* Beilaeufige Rueckmeldungen — EINE Stelle, und endlich mit aria-live (B-11). */}
<Meldungen />
{/* Erst laden, wenn wirklich gebraucht — ohne Platzhalter, beide sind Overlays. */}
<Suspense fallback={null}>
{paletteBereit && <CommandPalette />}
{drawerTab && (
<SystemDrawer
open
onClose={() => schubladeSetzen(undefined)}
defaultTab={drawerTab === "logs" ? "logs" : "maintenance"}
/>
)}
</Suspense>
{/* Sidebar (Desktop, ab md) */}
<aside className={cn(
"hidden md:flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out",
schieneEingeklappt ? "w-16" : "w-60"
)}>
<SidebarContent
collapsed={schieneEingeklappt}
onToggleCollapse={schieneUmschalten}
pfad={pfad}
health={health}
backendDown={backendDown}
/>
</aside>
{/* Sidebar (Mobile, Off-Canvas mit Backdrop) */}
<div
className={cn(
"md:hidden fixed inset-0 z-50 transition-opacity duration-200",
mobileNavOpen ? "opacity-100" : "pointer-events-none opacity-0"
)}
aria-hidden={!mobileNavOpen}
>
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileNavOpen(false)} />
<aside className={cn(
"absolute inset-y-0 left-0 flex w-72 max-w-[85vw] flex-col border-r border-border/40 bg-[hsl(224,28%,8%)] transition-transform duration-300 ease-in-out",
mobileNavOpen ? "translate-x-0" : "-translate-x-full"
)}>
<SidebarContent
collapsed={false}
onClose={() => setMobileNavOpen(false)}
pfad={pfad}
health={health}
backendDown={backendDown}
/>
</aside>
</div>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border/40 px-4 md:px-6 bg-card/20 backdrop-blur-sm">
<div className="flex min-w-0 items-center gap-3">
<button
onClick={() => setMobileNavOpen(true)}
className="md:hidden p-1.5 -ml-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
aria-label="Navigation öffnen"
>
<Menu className="h-5 w-5" aria-hidden="true" />
</button>
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active?.hint}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<ExpertToggle />
<a
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
target="_blank"
rel="noopener"
className="hidden sm:flex h-8 items-center rounded-md border border-border/40 bg-background/40 px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer font-semibold"
title="Bedien-Anleitung"
>
Hilfe
</a>
<button
onClick={() => palette(true)}
className="flex items-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
>
<CommandIcon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Suchen</span>
<kbd className="hidden sm:inline rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">{IS_MAC ? "⌘K" : "Strg+K"}</kbd>
</button>
</div>
</header>
{/* Bahnbreiten-Deckel (Befund B-10): Ohne ihn zieht sich das Cockpit auf einem
3440-px-Ultrawide auf ueber 3 000 px Breite; die Wurzel-Schriftgroesse waechst
dabei mit und macht die Zeilen GROESSER statt lesbarer. 1600 px sind die Grenze,
ab der eine Zeile nicht mehr am Stueck ueberblickt wird — der Rest bleibt Rand. */}
<main className="flex-1 overflow-y-auto scrollbar-thin">
<div className="mx-auto w-full max-w-[1600px] p-4 md:p-6">
<Suspense fallback={<ViewLoading />}>
<Outlet />
</Suspense>
</div>
</main>
{/* Zeigt den Zustand der Box IMMER, ohne den Arbeitsbereich zu blockieren (§4.1). */}
<Statusleiste />
</div>
</div>
)
}
// Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste
// und Mobile-Off-Canvas, damit beide immer identisch bleiben.
function SidebarContent({
collapsed, onToggleCollapse, onClose, pfad, health, backendDown,
}: {
collapsed: boolean
onToggleCollapse?: () => void
onClose?: () => void
pfad: string
health: Health | undefined
backendDown: boolean
}) {
const aktiv = navFuerPfad(pfad)
// Kein Prop-Drilling mehr: derselbe Query-Key, also dieselbe Anfrage — TanStack Query
// dedupliziert. Die Versionen stehen bewusst hier unten und nicht in der Statusleiste:
// Man schaut sie selten an, und die Leiste soll knapp bleiben.
const { data: sysStatus } = useSystemStatus()
return (
<>
<div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", collapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
<div className="flex items-center gap-2 overflow-hidden">
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20 shrink-0" />
{!collapsed && (
<div className="leading-tight">
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
<div className="text-xs text-muted-foreground">2.0</div>
</div>
)}
</div>
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
aria-label={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
title={collapsed ? "Maximieren" : "Minimieren"}
>
{collapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />}
</button>
)}
{onClose && (
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
aria-label="Navigation schließen"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
{(() => {
let lastGroup: string | null = null
return NAV.map((item) => {
const showGroupHeader = item.group !== lastGroup
lastGroup = item.group
const istAktiv = aktiv?.id === item.id
return (
<div key={item.id} className="space-y-1">
{showGroupHeader && (
collapsed ? (
lastGroup !== NAV[0].group && <div className="my-3 border-t border-border/30" />
) : (
<div className="mt-5 mb-1.5 px-3 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/40 first:mt-0 select-none">
{item.group}
</div>
)
)}
<Link
to={item.pfad}
onClick={onClose}
aria-current={istAktiv ? "page" : undefined}
className={cn(
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
istAktiv
? (collapsed ? "nav-active-collapsed" : "nav-active")
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
title={collapsed ? item.label : undefined}
aria-label={collapsed ? item.label : undefined}
>
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
{!collapsed && <span className="truncate">{item.label}</span>}
</Link>
</div>
)
})
})()}
</nav>
<div className={cn("py-3 text-xs text-muted-foreground border-t border-border/40 shrink-0", collapsed ? "px-2 text-center" : "px-5")}>
{collapsed ? (
<div className="flex justify-center">
<span className={cn(
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
backendDown || !health
? "bg-red-500"
: (!health.engine_reachable ? "bg-amber-500"
: (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse"))
)} title={backendDown
? "Zentrale antwortet nicht — Anzeigen evtl. veraltet"
: health
? (!health.engine_reachable ? "Engine offline"
: (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online"))
: "Backend offline"} />
</div>
) : (
<div className="space-y-2 text-left">
{backendDown ? (
<span className="flex items-center gap-2 text-red-400">
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
<span className="truncate">Zentrale antwortet nicht</span>
</span>
) : health ? (
<>
<span className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full animate-pulse", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} />
<span className="truncate">Engine {health.engine_reachable ? "online" : "offline"}</span>
</span>
{health.brain && !health.brain.ready && (
<span className="flex items-center gap-2 text-amber-400" title={`Agent-Hirn '${health.brain.model ?? "fast"}' lädt nicht/abgestürzt`}>
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
<span className="truncate">Hirn offline{health.brain.model ? ` (${health.brain.model})` : ""}</span>
</span>
)}
</>
) : (
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-500" /> <span className="truncate">Backend offline</span>
</span>
)}
{sysStatus?.versions && (
<div className="space-y-1 text-[9px] text-muted-foreground/60 mt-2 pt-2 border-t border-border/30">
<div className="truncate" title={sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.branch}-${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""} (${sysStatus.versions.mc2.date})` : "nicht gefunden"}>
<strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
</div>
<div className="truncate" title={sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.branch}-${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""} (${sysStatus.versions.engine.date})` : sysStatus.versions.engine?.version_text || "unbekannt"}>
<strong>Engine:</strong> {sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""}` : (sysStatus.versions.engine?.version_text?.split(" ").pop() || "—")}
</div>
<div className="truncate" title={sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.branch}-${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""} (${sysStatus.versions.hermes_agent.date})` : "nicht gefunden"}>
<strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
</div>
</div>
)}
</div>
)}
</div>
</>
)
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from "react"
import { ExternalLink, ScrollText, Settings } from "lucide-react"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
/** Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt. */
export function MehrMenue({ verbindung, onDienste, onEinstellungen, onSchliessen }: {
verbindung: ReactNode
onDienste: () => void
onEinstellungen: () => void
onSchliessen: () => void
}) {
return (
<Sheet open onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="bottom" className="border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]">
<SheetHeader>
<SheetTitle className="schild text-lg">Mehr</SheetTitle>
<SheetDescription className="sr-only">Weitere Bereiche</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-2 px-4">
{verbindung}
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base">
<ExternalLink className="size-5" aria-hidden /> Hermes-Dashboard öffnen
</a>
<button type="button" onClick={onDienste} className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base">
<ScrollText className="size-5" aria-hidden /> Dienste und Protokolle
</button>
<button type="button" onClick={onEinstellungen} className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base">
<Settings className="size-5" aria-hidden /> Einstellungen
</button>
</div>
</SheetContent>
</Sheet>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { X } from "lucide-react"
import { cn } from "cn"
import { entfernen, useMeldungen } from "@/lib/meldungen"
const FARBE = {
erfolg: "border-gruen-rand bg-gruen-grund text-gruen-text",
fehler: "border-rot-rand bg-rot-grund text-rot-text",
info: "border-cyan-rand bg-cyan-grund text-cyan-text",
}
/** Rückmeldungen unten rechts (am Handy über der Tab-Leiste), für Screenreader angesagt. */
export function Meldungen() {
const liste = useMeldungen()
return (
<div
aria-live="polite"
className="pointer-events-none fixed right-4 bottom-24 left-4 z-50 flex flex-col items-end gap-2 md:bottom-6 md:left-auto md:w-[420px]"
>
{liste.map((m) => (
<div
key={m.id}
role={m.art === "fehler" ? "alert" : "status"}
className={cn("pointer-events-auto flex w-full items-start gap-3 rounded-xl border px-4 py-3 text-[15px] shadow-lg", FARBE[m.art])}
>
<span className="min-w-0 flex-1">{m.text}</span>
<button type="button" onClick={() => entfernen(m.id)} aria-label="Meldung schließen" className="-m-2 p-2 opacity-70 hover:opacity-100">
<X className="size-4" />
</button>
</div>
))}
</div>
)
}
+172
View File
@@ -0,0 +1,172 @@
import { lazy, Suspense, useEffect, useState } from "react"
import { Link, Outlet } from "@tanstack/react-router"
import { Cpu, Ellipsis, ExternalLink, House, RefreshCw, ScrollText, Settings } from "lucide-react"
import { cn } from "cn"
import { useStrom, useStromLage } from "@/lib/strom"
import { Meldungen } from "./Meldungen"
// Schubladen werden erst beim Öffnen geladen — sie gehören nicht zum Normalfall, und die
// Radix-Dialog-Kette (Fokus-Falle, Scroll-Sperre) sprengte sonst das 140-KB-Budget.
const Dienste = lazy(() => import("@/views/Dienste").then((m) => ({ default: m.Dienste })))
const Einstellungen = lazy(() => import("@/views/Einstellungen").then((m) => ({ default: m.Einstellungen })))
const MehrMenue = lazy(() => import("./MehrMenue").then((m) => ({ default: m.MehrMenue })))
const NAV = [
{ to: "/", label: "Start", Icon: House },
{ to: "/updates", label: "Updates", Icon: RefreshCw },
{ to: "/modelle", label: "Modelle", Icon: Cpu },
] as const
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
const datum = new Intl.DateTimeFormat("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", timeZone: "Europe/Berlin" })
function Uhr() {
const [jetzt, setJetzt] = useState(() => new Date())
useEffect(() => {
const t = window.setInterval(() => setJetzt(new Date()), 20_000)
return () => window.clearInterval(t)
}, [])
return (
<div className="flex flex-col items-end leading-tight">
<span className="ziffern text-2xl font-medium">{uhrzeit.format(jetzt)}</span>
<span className="schild text-[13px] text-text-3">{datum.format(jetzt).replace(",", "")}</span>
</div>
)
}
function Verbindung() {
const lage = useStromLage()
const live = lage === "live"
return (
<div className="schild flex items-center gap-2 text-[15px] text-text-3" aria-live="polite">
<span
className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
/>
<span>{live ? "Box online" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
</div>
)
}
function Marke() {
return (
<div className="flex items-center gap-3">
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" aria-hidden>
<rect x="1.5" y="1.5" width="31" height="31" rx="8" stroke="var(--linie-stark)" strokeWidth="2" />
<path d="M7 18h5l3-7 4 13 3-6h5" stroke="var(--bernstein)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="font-anzeige text-[28px] leading-none font-bold tracking-[0.06em]">MC2</span>
<span className="hidden h-6 w-px bg-linie sm:block" />
<span className="schild hidden text-base tracking-[0.22em] text-text-3 sm:block">Box-Wart</span>
</div>
)
}
type Schublade = "dienste" | "einstellungen" | "mehr" | null
export function Shell() {
useStrom()
const [schublade, setSchublade] = useState<Schublade>(null)
const zu = () => setSchublade(null)
return (
<div className="mx-auto flex min-h-dvh w-full max-w-[1440px] flex-col gap-5 px-4 pt-4 pb-28 sm:px-6 md:gap-6 md:px-10 md:pt-8 md:pb-10">
<header className="flex items-center justify-between gap-4 md:gap-6">
<Marke />
<nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 md:flex">
{NAV.map(({ to, label }) => (
<Link
key={to}
to={to}
className="schild flex h-11 items-center rounded-[9px] border border-transparent px-5 text-base text-text-3 hover:text-foreground"
activeProps={{ className: "!border-linie-stark bg-erhaben !text-foreground" }}
activeOptions={{ exact: true }}
>
{label}
</Link>
))}
</nav>
<div className="flex items-center gap-4 md:gap-5">
<div className="hidden lg:block">
<Verbindung />
</div>
<div className="hidden items-center gap-1 md:flex">
<a
href="/hermes-ui/"
target="_blank"
rel="noopener"
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
title="Hermes-Dashboard öffnen"
aria-label="Hermes-Dashboard öffnen"
>
<ExternalLink className="size-5" />
</a>
<button
type="button"
onClick={() => setSchublade("dienste")}
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
title="Dienste und Protokolle"
aria-label="Dienste und Protokolle"
>
<ScrollText className="size-5" />
</button>
<button
type="button"
onClick={() => setSchublade("einstellungen")}
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
title="Einstellungen"
aria-label="Einstellungen"
>
<Settings className="size-5" />
</button>
</div>
<Uhr />
</div>
</header>
<main className="flex min-w-0 flex-col gap-5 md:gap-6">
<Outlet />
</main>
{/* Handy: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
<nav
aria-label="Hauptnavigation"
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur md:hidden"
>
{NAV.map(({ to, label, Icon }) => (
<Link
key={to}
to={to}
className="schild flex h-14 flex-col items-center justify-center gap-1 rounded-lg text-xs text-text-3"
activeProps={{ className: "bg-erhaben !text-foreground" }}
activeOptions={{ exact: true }}
>
<Icon className="size-5" aria-hidden />
{label}
</Link>
))}
<button
type="button"
onClick={() => setSchublade("mehr")}
className="schild flex h-14 flex-col items-center justify-center gap-1 rounded-lg text-xs text-text-3"
>
<Ellipsis className="size-5" aria-hidden />
Mehr
</button>
</nav>
<Suspense fallback={null}>
{schublade === "mehr" && (
<MehrMenue
verbindung={<Verbindung />}
onDienste={() => setSchublade("dienste")}
onEinstellungen={() => setSchublade("einstellungen")}
onSchliessen={zu}
/>
)}
{schublade === "dienste" && <Dienste offen onSchliessen={zu} />}
{schublade === "einstellungen" && <Einstellungen offen onSchliessen={zu} />}
</Suspense>
<Meldungen />
</div>
)
}
-104
View File
@@ -1,104 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { befehle, suchtext, type BefehlsKontext } from "./register"
import { NAV } from "@/nav"
function kontext(ueberschreiben: Partial<BefehlsKontext> = {}): BefehlsKontext {
return {
text: "",
navigate: vi.fn(),
schublade: vi.fn(),
melden: vi.fn(),
api: vi.fn(async () => ({}) as never),
...ueberschreiben,
}
}
describe("Befehls-Register", () => {
it("führt jedes Navigations-Ziel auf — genau einmal", () => {
const ziele = befehle("").filter((b) => b.gruppe === "gehe")
expect(ziele).toHaveLength(NAV.length)
expect(new Set(ziele.map((z) => z.id)).size).toBe(NAV.length)
})
it("zeigt Suche und „An Lucy“ erst, wenn etwas getippt ist", () => {
const leer = befehle("")
expect(leer.some((b) => b.gruppe === "finden")).toBe(false)
expect(leer.some((b) => b.gruppe === "lucy")).toBe(false)
const getippt = befehle("Wissensgraph-Ansicht bauen")
expect(getippt.filter((b) => b.gruppe === "finden")).toHaveLength(2)
expect(getippt.filter((b) => b.gruppe === "lucy")).toHaveLength(1)
})
// Unter acht Zeichen ist es kein Auftrag, sondern ein Tippfehler auf dem Weg zu
// einem Sprungziel. Sonst böte die Palette bei „mod" an, daraus eine Idee zu machen.
it("bietet kurze Eingaben NICHT als Idee an", () => {
expect(befehle("mod").some((b) => b.gruppe === "lucy")).toBe(false)
expect(befehle("modelle").some((b) => b.gruppe === "lucy")).toBe(false)
expect(befehle("modellwechsel").some((b) => b.gruppe === "lucy")).toBe(true)
})
// ── Die Sicherheitsregel ──────────────────────────────────────────────────
// Die Palette ist schnell genug, dass „Strg+K, mot, Enter" einen Dienst neu startet,
// bevor man es zu Ende gedacht hat. Alles Eingreifende MUSS deshalb `rueckfrage`
// tragen — die Oberfläche hält dann an und sagt erst, was passieren wird.
it("verlangt für jeden eingreifenden Befehl eine Rückfrage", () => {
const eingreifend = /neu starten|löschen|loeschen|deploy|zurücksetzen|reboot|entfernen/i
for (const b of befehle("etwas getipptes")) {
if (eingreifend.test(b.label)) {
expect(b.rueckfrage, `„${b.label}" muss nachfragen`).toBeTruthy()
}
}
})
it("nennt in der Rückfrage die Folge, nicht nur die Aktion", () => {
const motor = befehle("").find((b) => b.id === "tun:engine-neustart")!
expect(motor.rueckfrage).toBeTruthy()
// Der Text muss sagen, was der Nutzer DANACH merkt.
expect(motor.rueckfrage).toMatch(/Modelle/)
expect(motor.rueckfrage).toMatch(/aufwärmen|abbrechen|abbrechen/i)
})
it("lässt harmlose Befehle ohne Rückfrage durch", () => {
const wartung = befehle("").find((b) => b.id === "tun:wartung")!
expect(wartung.rueckfrage).toBeUndefined()
})
// ── Verhalten einzelner Befehle ───────────────────────────────────────────
it("öffnet die Schublade über die URL, nicht über ein Ereignis", () => {
const ctx = kontext()
befehle("").find((b) => b.id === "tun:logs")!.ausfuehren(ctx)
expect(ctx.schublade).toHaveBeenCalledWith("logs")
})
it("erfasst eine Idee zum Durchdenken — und startet keinen Bau", async () => {
const ctx = kontext({ text: "Wissensgraph-Ansicht bauen" })
await befehle(ctx.text).find((b) => b.gruppe === "lucy")!.ausfuehren(ctx)
expect(ctx.api).toHaveBeenCalledTimes(1)
const [pfad, init] = (ctx.api as ReturnType<typeof vi.fn>).mock.calls[0]
expect(pfad).toBe("/api/ideen")
const rumpf = JSON.parse((init as RequestInit).body as string)
// `art: "idee"` heisst durchdenken. `"projekt"` wuerde sofort ein Repo anlegen —
// das darf die Palette niemals aus einem Tastendruck heraus tun.
expect(rumpf.art).toBe("idee")
expect(rumpf.titel).toBe("Wissensgraph-Ansicht bauen")
})
it("kürzt einen überlangen Ideen-Titel und hebt den vollen Text in die Notiz", async () => {
const lang = "a".repeat(200)
const ctx = kontext({ text: lang })
await befehle(lang).find((b) => b.gruppe === "lucy")!.ausfuehren(ctx)
const rumpf = JSON.parse(
((ctx.api as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit).body as string,
)
expect(rumpf.titel.length).toBeLessThanOrEqual(160)
expect(rumpf.notiz).toBe(lang)
})
it("packt Suchwörter in den Suchtext, damit cmdk sie findet", () => {
const wartung = befehle("").find((b) => b.id === "tun:wartung")!
expect(suchtext(wartung)).toMatch(/neustart/) // steht nur in den Stichworten
expect(suchtext(wartung)).toMatch(/System-Wartung/)
})
})
-194
View File
@@ -1,194 +0,0 @@
import type { LucideIcon } from "lucide-react"
import {
Camera, CloudDownload, FileText, Lightbulb, RefreshCw, ScrollText, Search, Wrench,
} from "lucide-react"
import { NAV } from "@/nav"
// Befehls-Register der Palette (v3-Umbau P6, Spezifikation §4.6).
//
// Bis hierher listete die Palette die elf Navigations-Einträge — sie war ein Sprungbrett.
// Jetzt ist sie das primäre Bedienwerkzeug: Man tippt, was man will, nicht wo es liegt.
//
// VIER KATEGORIEN, in der Reihenfolge, in der man sie braucht:
// Gehe zu Sprungziele (wie bisher)
// Tun Aktionen — Dienste, Modelle, Wartung
// Finden Suche in Chronik und Wissens-Vault
// An Lucy den getippten Text direkt als Idee erfassen
//
// SICHERHEITSREGEL (aus der Spezifikation, hier hart verdrahtet): Alles, was neu startet,
// löscht oder deployt, trägt `rueckfrage` und wird zweistufig bestätigt. Die Palette ist
// zu schnell für einen unbedachten Treffer — zwei Tastendrücke und ein Dienst wäre weg.
export type BefehlsGruppe = "gehe" | "tun" | "finden" | "lucy"
export interface Befehl {
id: string
gruppe: BefehlsGruppe
label: string
hinweis?: string
icon: LucideIcon
/** Zusätzliche Wörter für die Suche — was der Nutzer tippen könnte, aber nicht dasteht. */
stichworte?: string
/** Gesetzt = zweistufige Rückfrage mit diesem Text, bevor irgendetwas passiert. */
rueckfrage?: string
/** Was der Befehl tut. `text` ist die aktuelle Eingabe der Palette. */
ausfuehren: (ctx: BefehlsKontext) => void | Promise<void>
}
export interface BefehlsKontext {
text: string
navigate: (pfad: string) => void
schublade: (tab: "wartung" | "logs") => void
melden: (art: "info" | "erfolg" | "warnung" | "fehler", text: string) => void
api: <T>(pfad: string, init?: RequestInit) => Promise<T>
}
export const GRUPPEN_TITEL: Record<BefehlsGruppe, string> = {
gehe: "Gehe zu",
tun: "Tun",
finden: "Finden",
lucy: "An Lucy",
}
/** Sprungziele — direkt aus der Navigations-Tabelle, damit es nie zwei Listen gibt. */
const ZIELE: Befehl[] = NAV.map((n) => ({
id: `gehe:${n.id}`,
gruppe: "gehe" as const,
label: n.label,
hinweis: n.hint,
icon: n.icon,
ausfuehren: ({ navigate }) => navigate(n.pfad),
}))
/** Aktionen. Bewusst wenige: nur was man wirklich aus dem Stand heraus tut. */
const AKTIONEN: Befehl[] = [
{
id: "tun:wartung",
gruppe: "tun",
label: "System-Wartung öffnen",
hinweis: "Dienste, Updates, Snapshot",
icon: Wrench,
stichworte: "pflege dienste neustart update",
ausfuehren: ({ schublade }) => schublade("wartung"),
},
{
id: "tun:logs",
gruppe: "tun",
label: "System-Logs öffnen",
hinweis: "Diagnose je Dienst",
icon: ScrollText,
stichworte: "fehler journal diagnose",
ausfuehren: ({ schublade }) => schublade("logs"),
},
{
id: "tun:updates",
gruppe: "tun",
label: "Nach Updates suchen",
hinweis: "apt + Engine + Agent prüfen",
icon: CloudDownload,
stichworte: "aktualisieren apt engine",
ausfuehren: async ({ api, melden }) => {
await api("/api/maintenance/check-updates", { method: "POST" })
melden("info", "Update-Suche läuft — das Ergebnis erscheint in der Wartung.")
},
},
{
id: "tun:snapshot",
gruppe: "tun",
label: "Snapshot anlegen",
hinweis: "Sicherung vor riskanten Schritten",
icon: Camera,
stichworte: "backup sicherung zeitmaschine",
ausfuehren: async ({ api, melden }) => {
await api("/api/system/backup", { method: "POST" })
melden("erfolg", "Snapshot angestoßen.")
},
},
{
id: "tun:engine-neustart",
gruppe: "tun",
label: "Motor neu starten",
hinweis: "llama-swap — alle Modelle fallen kurz weg",
icon: RefreshCw,
stichworte: "engine llama swap modell neu starten",
// Zweistufig: Ein Neustart wirft jedes geladene Modell aus dem Speicher, und ein
// grosses Modell braucht danach ueber 20 Sekunden zum Aufwaermen.
rueckfrage:
"Der Motor (llama-swap) wird neu gestartet. Alle geladenen Modelle fallen dabei aus " +
"dem Speicher und müssen neu aufwärmen — das dauert je nach Modell über 20 Sekunden. " +
"Laufende Antworten brechen ab.",
ausfuehren: async ({ api, melden }) => {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: "llama-swap" }),
})
melden(r.ok ? "erfolg" : "fehler",
r.ok ? "Motor neu gestartet." : `Neustart fehlgeschlagen: ${r.err || "Unbekannter Fehler"}`)
},
},
]
/** Suchen — führen in die jeweilige Ansicht, mit dem Suchwort in der URL. */
const SUCHEN = (text: string): Befehl[] => {
const q = text.trim()
if (!q) return []
return [
{
id: "finden:chronik",
gruppe: "finden",
label: `„${q}“ in der Chronik`,
hinweis: "Was die Box getan hat",
icon: Search,
ausfuehren: ({ navigate }) => navigate(`/chronik?q=${encodeURIComponent(q)}`),
},
{
id: "finden:wissen",
gruppe: "finden",
label: `„${q}“ im Wissens-Vault`,
hinweis: "Lucys Notizen",
icon: FileText,
ausfuehren: ({ navigate }) => navigate(`/wissen?suche=${encodeURIComponent(q)}`),
},
]
}
/** An Lucy — den getippten Text als Idee erfassen. Erfasst nur, startet nichts. */
const AN_LUCY = (text: string): Befehl[] => {
const q = text.trim()
if (q.length < 8) return [] // unter acht Zeichen ist es kein Auftrag, sondern ein Tippfehler
return [
{
id: "lucy:idee",
gruppe: "lucy",
label: `Als Idee erfassen: „${q.length > 60 ? q.slice(0, 57) + "…" : q}“`,
hinweis: "Wird nur notiert — gebaut wird erst auf deinen Klick",
icon: Lightbulb,
ausfuehren: async ({ api, melden, navigate }) => {
// Rumpf exakt wie im Ideen-Verfasser (routers/ideen.py → IdeeIn). `art: "idee"`
// heisst durchdenken, nicht bauen — die Palette startet nie einen Bau.
await api("/api/ideen", {
method: "POST",
body: JSON.stringify({
titel: q.length > 160 ? q.slice(0, 157) + "…" : q,
notiz: q.length > 160 ? q : "",
welt: "ide",
tiefe: "",
ziel: "",
art: "idee",
}),
})
melden("erfolg", "Als Idee erfasst.")
navigate("/ideen")
},
},
]
}
/** Das vollständige Register für die aktuelle Eingabe. */
export function befehle(text: string): Befehl[] {
return [...ZIELE, ...AKTIONEN, ...SUCHEN(text), ...AN_LUCY(text)]
}
/** Suchtext eines Befehls — cmdk filtert darüber. */
export const suchtext = (b: Befehl) =>
[b.label, b.hinweis, b.stichworte, GRUPPEN_TITEL[b.gruppe]].filter(Boolean).join(" ")
+18 -185
View File
@@ -1,197 +1,30 @@
import { lazy } from "react"
import {
createRootRoute,
createRoute,
createRouter,
redirect,
useNavigate,
} from "@tanstack/react-router"
import { Shell } from "@/App"
import { NAV } from "@/nav"
import { RouteFehler } from "@/components/RouteFehler"
import { CockpitView } from "@/views/cockpit/CockpitView"
import { createRootRoute, createRoute, createRouter, lazyRouteComponent, Navigate } from "@tanstack/react-router"
import { Shell } from "./Shell"
import { StartSeite } from "@/views/Start"
// Routenbaum (v3-Umbau P2).
//
// Bewusst code-based statt dateibasiert: Es sind zwölf Routen, und sie stehen dadurch
// alle an EINER Stelle — inklusive dem, was in den Suchparametern erlaubt ist. Eine
// Datei-Konvention würde diese Übersicht auf zwölf Dateien verteilen, ohne dass jemand
// etwas davon hätte.
//
// Code-Splitting bleibt exakt wie bisher: die Views hängen an React.lazy, das Cockpit als
// Startseite lädt eifrig. Kein Chunk verschiebt sich durch den Router.
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView })))
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
const IdeenView = lazy(() => import("@/features/ideen/IdeenAnsicht").then((m) => ({ default: m.IdeenView })))
const SkillsView = lazy(() => import("@/views/SkillsView").then((m) => ({ default: m.SkillsView })))
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
// ── Suchparameter ───────────────────────────────────────────────────────────
// Regel für das, was hier steht: Alles, was ein Kollege per Link teilen können soll und
// was einen Reload überleben muss. Nichts Flüchtiges (Hover, Entwürfe im Eingabefeld).
/** Wurzel: die System-Schublade. Gehört hierher, weil sie über JEDER Ansicht liegt. */
export interface WurzelSuche {
system?: "wartung" | "logs"
}
const wurzelSuche = (roh: Record<string, unknown>): WurzelSuche => {
const s = roh.system
return s === "wartung" || s === "logs" ? { system: s } : {}
}
export const rootRoute = createRootRoute({
const wurzel = createRootRoute({
component: Shell,
validateSearch: wurzelSuche,
notFoundComponent: () => (
<RouteFehler
titel="Diese Seite gibt es nicht"
text="Der Link zeigt auf eine Ansicht, die Mission Control nicht kennt. Vielleicht stammt er aus einer älteren Fassung."
/>
),
// Alte Lesezeichen (/ideen, /wissen, /cockpit …) führen einfach zum Start.
notFoundComponent: () => <Navigate to="/" />,
})
// Wurzelpfad → Cockpit. `redirect` statt einer Kopie der Startseite: sonst gäbe es
// zwei URLs für denselben Inhalt und die aktive Markierung in der Sidebar flackert.
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
throw redirect({ to: "/cockpit" })
},
// Der Start ist der Normalfall und kommt im ersten Bündel mit; Updates und Modelle
// werden erst beim ersten Besuch nachgeladen (Budget der CI-Ampel: 140 KB gzip).
const start = createRoute({ getParentRoute: () => wurzel, path: "/", component: StartSeite })
const updates = createRoute({
getParentRoute: () => wurzel,
path: "/updates",
component: lazyRouteComponent(() => import("@/views/Updates"), "UpdatesSeite"),
})
/** Eine Route je Ansicht — kurze Schreibweise, weil sich nur Pfad und Bauteil ändern.
* Generisch über den Pfad, damit TanStack Router den Literal-Typ behält: mit
* `path: string` verlöre `navigate({ to: "/cockpit" })` seine Typprüfung. */
function ansicht<P extends string>(path: P, component: () => React.ReactNode) {
return createRoute({
getParentRoute: () => rootRoute,
path,
component,
// Fehlergrenze PRO Route (Befund B-13): Vorher hing eine einzige Grenze in main.tsx
// um die ganze App — ein Renderfehler in den Modellen nahm Cockpit, Konsole und
// Chronik mit. Jetzt bleiben Schiene, Kopfzeile und Statusanzeige stehen.
errorComponent: ({ error, reset }) => (
<RouteFehler
titel="Diese Ansicht ist abgestürzt"
text="Der Rest von Mission Control läuft weiter. Erneut versuchen — oder in eine andere Ansicht wechseln."
fehler={error}
onErneut={reset}
/>
),
})
}
// Das Cockpit verlinkt in andere Bereiche und bekam den Sprung bisher als Prop von der
// App gereicht. Jetzt kommt er aus dem Router — die Ansicht selbst bleibt unverändert.
function CockpitSeite() {
const navigate = useNavigate()
return <CockpitView onNavigate={(v) => {
const ziel = NAV.find((n) => n.id === v)
if (ziel) navigate({ to: ziel.pfad })
}} />
}
const cockpitRoute = ansicht("/cockpit", () => <CockpitSeite />)
const skillsRoute = ansicht("/skills", () => <SkillsView />)
/** Chronik: `?q=` filtert den Verlauf — Ziel der „Finden"-Einträge der Palette (P6). */
export interface ChronikSuche {
q?: string
}
const chronikRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/chronik",
validateSearch: (roh: Record<string, unknown>): ChronikSuche =>
typeof roh.q === "string" && roh.q ? { q: roh.q } : {},
component: () => <ChronikView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Chronik ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
const verbindenRoute = ansicht("/verbinden", () => <ConnectView />)
const konsoleRoute = ansicht("/konsole", () => <KonsoleView />)
const anleitungRoute = ansicht("/anleitung", () => <GuideView />)
const agentRoute = ansicht("/agent", () => <AgentView />)
/** Ideen: `?offen=<id>` hält die aufgeklappte Karte fest (Befund B-05).
* Vorher lag das in `useState` — wer während eines laufenden Workers neu lud,
* verlor seinen Platz in der Liste. */
export interface IdeenSuche {
offen?: string
}
const ideenRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/ideen",
validateSearch: (roh: Record<string, unknown>): IdeenSuche =>
typeof roh.offen === "string" && roh.offen ? { offen: roh.offen } : {},
component: () => <IdeenView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Ideen-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
/** Wissen: `?datei=` macht jede Notiz verlinkbar, `?suche=` trägt das Suchwort
* aus der Befehlspalette herein (P6). */
export interface WissenSuche {
datei?: string
suche?: string
}
const wissenRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/wissen",
validateSearch: (roh: Record<string, unknown>): WissenSuche => ({
...(typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {}),
...(typeof roh.suche === "string" && roh.suche ? { suche: roh.suche } : {}),
}),
component: () => <WissenView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Wissens-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
/** Modelle: `?reiter=` — der Reiter war lokaler State und damit nicht teilbar. */
export type ModellReiter = "werkbank" | "routing" | "discover"
export interface ModelleSuche {
reiter?: ModellReiter
}
const modelleRoute = createRoute({
getParentRoute: () => rootRoute,
const modelle = createRoute({
getParentRoute: () => wurzel,
path: "/modelle",
validateSearch: (roh: Record<string, unknown>): ModelleSuche => {
const r = roh.reiter
return r === "werkbank" || r === "routing" || r === "discover" ? { reiter: r } : {}
},
component: () => <ModelsView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Der Modell-Manager ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
component: lazyRouteComponent(() => import("@/views/Modelle"), "ModelleSeite"),
})
export const routeTree = rootRoute.addChildren([
indexRoute,
cockpitRoute,
ideenRoute,
skillsRoute,
chronikRoute,
wissenRoute,
verbindenRoute,
konsoleRoute,
anleitungRoute,
modelleRoute,
agentRoute,
])
export const router = createRouter({
routeTree,
// Ein LAN-Gerät: der Verlauf muss nicht scrollen-restaurieren, aber die Position
// oben zu haben ist beim Ansichtswechsel richtig.
scrollRestoration: true,
defaultPreload: false,
routeTree: wurzel.addChildren([start, updates, modelle]),
defaultPreload: "intent",
})
declare module "@tanstack/react-router" {
-80
View File
@@ -1,80 +0,0 @@
import { useEffect } from "react"
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"
import { useMC, useMeldungen, type MeldungsArt } from "@/app/store"
import { cn } from "@/lib/utils"
// Meldungs-Region (v3-Umbau P3, Befund B-11).
//
// Vorher gab es im ganzen Frontend KEIN einziges `aria-live`. Dienst neu gestartet,
// Idee angenommen, Modell geladen, Backend weg — für Screenreader war all das stumm,
// und für sehende Nutzer stand es je nach Ansicht irgendwo anders (oder in einem Dialog,
// den man wegklicken musste).
//
// Jetzt eine Stelle für alle beiläufigen Rückmeldungen. `role="status"` mit
// `aria-live="polite"` heißt: vorlesen, aber den Nutzer nicht unterbrechen. Fehler
// bekommen `assertive` — die dürfen unterbrechen.
//
// Dialoge bleiben, was sie sind: Sie fragen etwas und warten auf eine Antwort. Hier
// landet nur, was man zur Kenntnis nimmt.
const AUSBLENDEN_NACH_MS = 6_000
const STIL: Record<MeldungsArt, { rand: string; farbe: string; Icon: typeof Info }> = {
info: { rand: "border-sky-500/40", farbe: "text-sky-300", Icon: Info },
erfolg: { rand: "border-emerald-500/40", farbe: "text-emerald-300", Icon: CheckCircle2 },
warnung: { rand: "border-amber-500/40", farbe: "text-amber-300", Icon: AlertTriangle },
fehler: { rand: "border-red-500/40", farbe: "text-red-300", Icon: XCircle },
}
export function Meldungen() {
const meldungen = useMeldungen()
const meldungWeg = useMC((s) => s.meldungWeg)
// Alte Meldungen räumen sich selbst weg. Fehler bleiben stehen — wer einen Fehler
// verpasst, weil er gerade woanders hinsah, hat nichts davon.
useEffect(() => {
const uhren = meldungen
.filter((m) => m.art !== "fehler")
.map((m) =>
window.setTimeout(
() => meldungWeg(m.id),
Math.max(1_000, AUSBLENDEN_NACH_MS - (Date.now() - m.seit)),
),
)
return () => uhren.forEach(window.clearTimeout)
}, [meldungen, meldungWeg])
return (
<div
className="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-full max-w-sm flex-col gap-2"
role="status"
aria-live="polite"
aria-atomic="false"
>
{meldungen.map((m) => {
const { rand, farbe, Icon } = STIL[m.art]
return (
<div
key={m.id}
className={cn(
"pointer-events-auto flex items-start gap-2.5 rounded-lg border bg-card/95 px-3.5 py-2.5 shadow-xl backdrop-blur",
rand,
)}
// Fehler duerfen unterbrechen, alles andere wartet auf eine Pause.
aria-live={m.art === "fehler" ? "assertive" : "polite"}
>
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", farbe)} aria-hidden="true" />
<p className="flex-1 text-xs leading-relaxed text-foreground">{m.text}</p>
<button
onClick={() => meldungWeg(m.id)}
aria-label="Meldung schließen"
className="shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
)
})}
</div>
)
}
-203
View File
@@ -1,203 +0,0 @@
import { Link } from "@tanstack/react-router"
import { useHealth, useRouting, useSystemStatus, useTokenStats } from "@/lib/queries"
import { useLetzteMetrik, useSysHistory, useTokHistory } from "@/lib/metricsStore"
import { useStromLage } from "@/app/store"
import { gb } from "@/lib/format"
import { cn } from "@/lib/utils"
// Die Statusleiste (v3-Umbau P3, Spezifikation §4.1).
//
// Sie zeigt den Zustand der Box IMMER und blockiert dabei nie den Arbeitsbereich.
// Vorher stand dasselbe verstreut: die Ampel unten in der Sidebar, Auslastung nur im
// Cockpit, das aktive Modell nur im Modell-Manager. Wer in der Konsole arbeitete, sah
// gar nichts — und merkte einen Engine-Ausfall erst, wenn etwas nicht ging.
//
// Die wichtigste Zahl hier ist nicht CPU oder RAM: Es ist der geteilte Speicher. Die Box
// hat 128 GB Unified Memory für alles — Modelle, System, Dienste. Deshalb steht er als
// BALKEN da und nicht als Prozentzahl: Man muss auf einen Blick sehen, wie viel Platz ein
// weiteres Modell noch hätte.
/** Sekunden → „12 T 4 Std". Bewusst grob: auf die Minute genau interessiert hier niemand. */
function betriebszeit(s: number | null | undefined): string | null {
if (s == null || s < 0) return null
const tage = Math.floor(s / 86_400)
const std = Math.floor((s % 86_400) / 3_600)
const min = Math.floor((s % 3_600) / 60)
if (tage > 0) return `${tage} T ${std} Std`
if (std > 0) return `${std} Std ${min} min`
return `${min} min`
}
/** Winziger Sparkline-Pfad aus Messwerten. Eigenes SVG statt einer Diagramm-Bibliothek:
* Für eine 60-Pixel-Linie in einer Statusleiste wäre alles andere Verschwendung. */
function Sparkline({ werte, farbe, breite = 56, hoehe = 14 }: {
werte: number[]
farbe: string
breite?: number
hoehe?: number
}) {
if (werte.length < 2) return <div style={{ width: breite, height: hoehe }} aria-hidden="true" />
const max = Math.max(...werte, 1)
const schritt = breite / (werte.length - 1)
const d = werte
.map((v, i) => `${i === 0 ? "M" : "L"}${(i * schritt).toFixed(1)},${(hoehe - (v / max) * hoehe).toFixed(1)}`)
.join(" ")
return (
<svg width={breite} height={hoehe} viewBox={`0 0 ${breite} ${hoehe}`} aria-hidden="true" className="shrink-0">
<path d={d} fill="none" stroke={farbe} strokeWidth="1.25" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
function Feld({ children, titel, className }: {
children: React.ReactNode
titel?: string
className?: string
}) {
return (
<div
title={titel}
className={cn("flex shrink-0 items-center gap-1.5 border-r border-border/30 px-3", className)}
>
{children}
</div>
)
}
export function Statusleiste() {
const { data: health, isError: zentraleWeg } = useHealth()
const { data: sys } = useSystemStatus()
const { data: routing } = useRouting()
const { data: tokens } = useTokenStats()
const sysVerlauf = useSysHistory()
const tokVerlauf = useTokHistory()
const lage = useStromLage()
const engineOk = !zentraleWeg && !!health?.engine_reachable
const hirnOk = engineOk && (health?.brain ? health.brain.ready : true)
// Rollen-Alias statt Dateiname: „fast", nicht „Qwen3.6-35B-A3B-Q4_K_M.gguf".
// (Merksatz aus dem Projekt: Rollen-Aliase statt Modellnamen.)
const aktivesModell = health?.brain?.role ?? routing?.lanes?.[0]?.name ?? null
// Live-Werte kommen aus dem Strom (jede Sekunde); die Query ist nur der Stand vom
// Seitenaufbau und der Rückfall, wenn der Strom gerissen ist (v3-Umbau P4).
const live = useLetzteMetrik()
const cpuProzent = live?.cpu ?? sys?.cpu?.percent ?? 0
const gpuProzent = live?.gpu ?? sys?.gpu?.busy_percent ?? null
const ramProzent = live?.ram ?? sys?.ram?.percent ?? 0
const ramBelegt = live?.ram_used ?? sys?.ram?.used
const ramGesamt = live?.ram_total ?? sys?.ram?.total
const tempMax = Math.max(live?.temp_cpu ?? sys?.temp?.cpu ?? 0, live?.temp_gpu ?? sys?.temp?.gpu ?? 0)
const durchsatz = tokVerlauf.length ? tokVerlauf[tokVerlauf.length - 1].completion : 0
const zeit = betriebszeit(live?.uptime_s ?? sys?.uptime_s)
// Die Anzeige beschreibt, WORAUF man gerade schaut — und muss dafür zwei Dinge
// zusammenbringen: den Ereignisstrom UND die Erreichbarkeit der Zentrale.
//
// Beim Prüfen gegen die noch nicht aktualisierte Box aufgefallen: Dort gibt es
// /api/stream nicht, der SPA-Catch-all beantwortet den Pfad mit index.html, und
// EventSource scheitert. Die Leiste sagte daraufhin „Getrennt" — obwohl die Poller
// sauber lieferten und die Zahlen stimmten. Eine Anzeige, die vor korrekten Daten
// warnt, ist genauso falsch wie eine, die veraltete verschweigt.
//
// Getrennt = die Zentrale antwortet nicht. Die Zahlen sind wirklich alt.
// Nachlauf = kein Strom, aber die Zentrale antwortet. Die Zahlen stimmen,
// sie kommen nur im Takt statt sofort.
// Live = Strom steht, Änderungen erscheinen unmittelbar.
const anzeige = zentraleWeg ? "getrennt" : lage === "live" ? "live" : "nachlauf"
const LAGE_STIL = {
live: { punkt: "bg-emerald-500", text: "Live", titel: "Ereignisstrom steht — Änderungen erscheinen sofort." },
nachlauf: { punkt: "bg-amber-500", text: "Nachlauf", titel: "Kein Ereignisstrom — die Zahlen stimmen, sie kommen im Takt statt sofort." },
getrennt: { punkt: "bg-red-500", text: "Getrennt", titel: "Die Zentrale antwortet nicht — die Zahlen sind veraltet." },
} as const
const lageStil = LAGE_STIL[anzeige]
return (
<footer
className="flex h-11 shrink-0 items-stretch overflow-x-auto border-t border-border/40 bg-card/40 font-mono text-[11px] backdrop-blur-sm scrollbar-thin"
aria-label="Zustand der Box"
>
{/* Ampel: Motor + Hirn. Der Klick führt dorthin, wo man es reparieren kann. */}
<Link to="/agent" className="flex shrink-0 items-center gap-2.5 border-r border-border/30 px-3 transition-colors hover:bg-accent/50">
<span className="flex items-center gap-1.5" title={engineOk ? "Motor (Engine) läuft" : "Motor (Engine) läuft nicht"}>
<span className={cn("h-2 w-2 rounded-full", engineOk ? "bg-emerald-500" : "bg-red-500")} aria-hidden="true" />
<span className={engineOk ? "text-muted-foreground" : "font-semibold text-red-400"}>Motor</span>
</span>
<span className="flex items-center gap-1.5" title={hirnOk ? "Lucys Hirn ist bereit" : "Lucys Hirn ist nicht bereit"}>
<span className={cn("h-2 w-2 rounded-full", hirnOk ? "bg-emerald-500" : "bg-amber-500")} aria-hidden="true" />
<span className={hirnOk ? "text-muted-foreground" : "font-semibold text-amber-400"}>Hirn</span>
</span>
</Link>
{/* Aktives Modell + Durchsatz */}
<Feld titel="Aktive Rolle und aktueller Ausgabe-Durchsatz">
<span className="font-semibold text-primary">{aktivesModell ?? "—"}</span>
<span className="tabular-nums text-muted-foreground">{durchsatz > 0 ? `${durchsatz.toFixed(0)} t/s` : "leerlauf"}</span>
<Sparkline werte={tokVerlauf.map((p) => p.completion)} farbe="hsl(172 72% 50%)" />
</Feld>
{/* Geteilter Speicher — die Kernzahl dieser Box, deshalb als Balken. */}
<Feld titel={ramBelegt != null && ramGesamt != null ? `Geteilter Speicher (Unified Memory): ${gb(ramBelegt)} von ${gb(ramGesamt)} GB belegt` : "Speicher"}>
<span className="text-muted-foreground">Speicher</span>
<div className="h-2 w-24 overflow-hidden rounded-sm border border-border/50 bg-background/60" aria-hidden="true">
<div
className={cn("h-full transition-[width] duration-500",
ramProzent > 90 ? "bg-red-500" : ramProzent > 75 ? "bg-amber-500" : "bg-primary")}
style={{ width: `${Math.min(100, ramProzent)}%` }}
/>
</div>
<span className="tabular-nums text-foreground">
{ramBelegt != null && ramGesamt != null ? `${gb(ramBelegt)}/${gb(ramGesamt)} GB` : "—"}
</span>
</Feld>
{/* Auslastung */}
<Feld titel="Auslastung von Prozessor und Grafikeinheit">
<span className="text-muted-foreground">CPU</span>
<span className="tabular-nums text-foreground">{Math.round(cpuProzent)} %</span>
<Sparkline werte={sysVerlauf.map((p) => p.cpu)} farbe="hsl(199 89% 58%)" />
{gpuProzent != null && (
<>
<span className="ml-1 text-muted-foreground">GPU</span>
<span className="tabular-nums text-foreground">{Math.round(gpuProzent)} %</span>
</>
)}
</Feld>
{/* Temperatur — ab 85 °C bernsteinfarben, das ist bei dieser APU die Grenze,
ab der die Taktrate fällt und alles langsamer wird. */}
{tempMax > 0 && (
<Feld titel="Höchste gemessene Temperatur (Prozessor oder Grafikeinheit)">
<span className="text-muted-foreground">Temp</span>
<span className={cn("tabular-nums", tempMax >= 85 ? "font-semibold text-amber-400" : "text-foreground")}>
{Math.round(tempMax)} °C
</span>
</Feld>
)}
{zeit && (
<Feld titel="Betriebszeit seit dem letzten Neustart der Box">
<span className="text-muted-foreground">Läuft seit</span>
<span className="tabular-nums text-foreground">{zeit}</span>
</Feld>
)}
{tokens && (
<Feld titel="Seit Beginn verarbeitete Token und die damit gesparten Cloud-Kosten">
<span className="tabular-nums text-muted-foreground">
{tokens.total_tokens.toLocaleString("de-DE")} Token · {tokens.saved_eur.toFixed(0)} € gespart
</span>
</Feld>
)}
{/* Ehrlichkeits-Anzeige: sagt, WORAUF man gerade schaut. Ohne sie altert die
Oberflaeche still weiter, wenn der Strom reisst (Befund B-15). */}
<div className="ml-auto flex shrink-0 items-center gap-1.5 px-3" title={lageStil.titel}>
<span className={cn("h-2 w-2 rounded-full", lageStil.punkt, anzeige === "live" && "animate-pulse")} aria-hidden="true" />
<span className="text-muted-foreground">{lageStil.text}</span>
</div>
</footer>
)
}
-85
View File
@@ -1,85 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest"
import { useMC, melden, stromLageSetzen } from "./store"
function zuruecksetzen() {
useMC.setState({
ui: { schieneEingeklappt: false, paletteOffen: false, expertenmodus: false },
strom: { lage: "getrennt", letztesEreignis: null },
meldungen: [],
})
}
describe("Store", () => {
beforeEach(zuruecksetzen)
it("schaltet die Schiene um", () => {
useMC.getState().schieneUmschalten()
expect(useMC.getState().ui.schieneEingeklappt).toBe(true)
useMC.getState().schieneUmschalten()
expect(useMC.getState().ui.schieneEingeklappt).toBe(false)
})
it("öffnet und schließt die Palette", () => {
useMC.getState().palette(true)
expect(useMC.getState().ui.paletteOffen).toBe(true)
useMC.getState().palette(false)
expect(useMC.getState().ui.paletteOffen).toBe(false)
})
it("nimmt Meldungen entgegen und gibt jeder einen eigenen Schlüssel", () => {
melden("erfolg", "Dienst neu gestartet")
melden("erfolg", "Dienst neu gestartet")
const m = useMC.getState().meldungen
expect(m).toHaveLength(2)
expect(m[0].id).not.toBe(m[1].id)
})
// Ohne Deckel wächst die Liste bei einem Fehlersturm ins Unendliche und überdeckt
// die halbe Oberfläche.
it("hält höchstens sechs Meldungen und wirft die ältesten weg", () => {
for (let i = 0; i < 10; i++) melden("info", `Meldung ${i}`)
const m = useMC.getState().meldungen
expect(m).toHaveLength(6)
expect(m[0].text).toBe("Meldung 4")
expect(m[5].text).toBe("Meldung 9")
})
it("entfernt eine Meldung gezielt", () => {
melden("warnung", "eins")
melden("warnung", "zwei")
const id = useMC.getState().meldungen[0].id
useMC.getState().meldungWeg(id)
expect(useMC.getState().meldungen.map((x) => x.text)).toEqual(["zwei"])
})
it("führt die Lage des Ereignisstroms", () => {
stromLageSetzen("live")
expect(useMC.getState().strom.lage).toBe("live")
stromLageSetzen("nachlauf")
expect(useMC.getState().strom.lage).toBe("nachlauf")
})
})
// Die Regel aus dem Kopfkommentar des Stores, als Test: Hier darf NICHTS liegen, was
// in die URL oder in TanStack Query gehört — und schon gar kein Geheimnis (Befund B-01).
describe("Store hält nur, was er halten darf", () => {
beforeEach(zuruecksetzen)
it("merkt sich ausschließlich Bedienvorlieben", () => {
useMC.getState().schieneUmschalten()
useMC.getState().expertenmodus(true)
useMC.getState().palette(true)
stromLageSetzen("live")
melden("info", "sollte nicht überleben")
const gespeichert = JSON.parse(localStorage.getItem("mc_ui") ?? "{}")
expect(gespeichert.state).toEqual({
ui: { schieneEingeklappt: true, expertenmodus: true },
})
// Weder der Offen-Zustand der Palette noch Strom oder Meldungen wandern in den
// Speicher: alles drei wäre beim nächsten Start gelogen.
expect(JSON.stringify(gespeichert)).not.toContain("paletteOffen")
expect(JSON.stringify(gespeichert)).not.toContain("meldungen")
expect(JSON.stringify(gespeichert)).not.toContain("strom")
})
})
-119
View File
@@ -1,119 +0,0 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
// Der EINE Client-Speicher (v3-Umbau P3, Befund B-08/B-09).
//
// Vorher verteilte sich derselbe Zweck über fünf Dateien: zwei handgebaute
// `useSyncExternalStore`-Speicher (Metrik-Verlauf, Expertenmodus) und drei
// `CustomEvent`-Kanäle (`mc-navigate`, `open-system-drawer`, `mc-expert-mode-change`,
// dazu in P2 `mc-palette-open`). Das funktionierte — aber ein Tippfehler im
// Ereignisnamen fiel erst zur Laufzeit auf, und niemand konnte auf einen Blick sagen,
// welcher Zustand es überhaupt gibt.
//
// DIE REGEL, nach der hier etwas landet:
// · Steht es in der URL? → gehört in den Router (app/router.tsx), nicht hierher.
// · Kommt es vom Server? → gehört in TanStack Query, nicht hierher.
// · Ist es flüchtig (Hover, Entwurf im Eingabefeld)? → useState in der Komponente.
// · Sonst: hier.
//
// Der Metrik-Verlauf bleibt bewusst DRAUSSEN (lib/metricsStore.ts). Er nimmt ab P4
// jede Sekunde einen Messpunkt entgegen; ein Store-Update pro Sekunde würde jede
// abonnierende Komponente neu rendern. Ein eigener `useSyncExternalStore` außerhalb
// von React ist dafür das richtige Werkzeug — und bleibt es.
export type MeldungsArt = "info" | "erfolg" | "warnung" | "fehler"
export interface Meldung {
id: string
art: MeldungsArt
text: string
/** Zeitpunkt in ms — die Meldungsliste blendet Alte selbst aus. */
seit: number
}
/** Zustand des Ereignisstroms. Ab P4 füttert ihn der Stream selbst; in P3 setzt ihn
* lib/events.ts, damit die Statusleiste schon jetzt die Wahrheit zeigt. */
export type StromLage = "live" | "nachlauf" | "getrennt"
interface MC {
ui: {
schieneEingeklappt: boolean
paletteOffen: boolean
expertenmodus: boolean
}
strom: {
lage: StromLage
letztesEreignis: number | null
}
meldungen: Meldung[]
schieneUmschalten: () => void
palette: (offen: boolean) => void
expertenmodus: (an: boolean) => void
stromLage: (lage: StromLage) => void
stromEreignis: () => void
melden: (art: MeldungsArt, text: string) => void
meldungWeg: (id: string) => void
}
// Eigener Zähler statt Date.now()/Math.random(): zwei Meldungen in derselben
// Millisekunde bekamen sonst denselben Schlüssel, und React zeigte nur eine an.
let meldungsZaehler = 0
export const useMC = create<MC>()(
persist(
(set) => ({
ui: { schieneEingeklappt: false, paletteOffen: false, expertenmodus: false },
strom: { lage: "getrennt", letztesEreignis: null },
meldungen: [],
schieneUmschalten: () =>
set((s) => ({ ui: { ...s.ui, schieneEingeklappt: !s.ui.schieneEingeklappt } })),
palette: (offen) => set((s) => ({ ui: { ...s.ui, paletteOffen: offen } })),
expertenmodus: (an) => set((s) => ({ ui: { ...s.ui, expertenmodus: an } })),
stromLage: (lage) => set((s) => ({ strom: { ...s.strom, lage } })),
stromEreignis: () =>
set((s) => ({ strom: { ...s.strom, letztesEreignis: Date.now() } })),
melden: (art, text) =>
set((s) => ({
meldungen: [
...s.meldungen,
{ id: `m${++meldungsZaehler}`, art, text, seit: Date.now() },
].slice(-6), // mehr als sechs liest ohnehin niemand
})),
meldungWeg: (id) => set((s) => ({ meldungen: s.meldungen.filter((m) => m.id !== id) })),
}),
{
name: "mc_ui",
// NUR Bedienvorlieben werden gemerkt. Kein Geheimnis (siehe P1/B-01), kein
// Serverdatum, kein Stromzustand — der wäre beim nächsten Start ohnehin gelogen.
partialize: (s) => ({
ui: { schieneEingeklappt: s.ui.schieneEingeklappt, expertenmodus: s.ui.expertenmodus },
}),
merge: (gespeichert, aktuell) => {
const g = gespeichert as { ui?: Partial<MC["ui"]> } | undefined
return { ...aktuell, ui: { ...aktuell.ui, ...(g?.ui ?? {}) } }
},
},
),
)
// ── Schmale Zugriffe ────────────────────────────────────────────────────────
// Komponenten sollen nur das abonnieren, was sie wirklich brauchen — sonst rendert
// die Schiene neu, weil eine Meldung kam.
export const useExpertenmodus = () => useMC((s) => s.ui.expertenmodus)
export const useSchieneEingeklappt = () => useMC((s) => s.ui.schieneEingeklappt)
export const usePaletteOffen = () => useMC((s) => s.ui.paletteOffen)
export const useStromLage = () => useMC((s) => s.strom.lage)
export const useMeldungen = () => useMC((s) => s.meldungen)
/** Für Code außerhalb von React (lib/events.ts, api-Fehlerbehandlung). */
export const melden = (art: MeldungsArt, text: string) => useMC.getState().melden(art, text)
export const stromLageSetzen = (lage: StromLage) => useMC.getState().stromLage(lage)
export const stromEreignis = () => useMC.getState().stromEreignis()
@@ -1,38 +0,0 @@
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 Canvas-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>
)
}
}
-27
View File
@@ -1,27 +0,0 @@
import type { Capabilities } from "@/lib/api"
function Chip({ children, tone = "muted" }: { children: React.ReactNode; tone?: "muted" | "primary" | "warn" }) {
const tones = {
muted: "bg-muted text-muted-foreground",
primary: "bg-primary/15 text-primary",
warn: "bg-amber-500/15 text-amber-500",
}
return (
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${tones[tone]}`}>{children}</span>
)
}
export function CapsChips({ caps }: { caps?: Capabilities }) {
if (!caps) return null
return (
<span className="inline-flex flex-wrap gap-1">
{caps.coder && <Chip>💻 Code</Chip>}
{caps.vision && <Chip>👁 Bild</Chip>}
{caps.reasoning && <Chip>🧠 Reason</Chip>}
{caps.moe && <Chip tone="primary">🧩 MoE{caps.active_b ? `·${caps.active_b}b` : ""}</Chip>}
{caps.tools === "yes" && <Chip tone="primary">🛠 Tools</Chip>}
{caps.tools === "likely" && <Chip tone="warn">🛠 Tools?</Chip>}
{caps.embedding && <Chip>🔢 Embed</Chip>}
</span>
)
}
-161
View File
@@ -1,161 +0,0 @@
import { useCallback, useMemo, useState } from "react"
import { Command } from "cmdk"
import { useNavigate } from "@tanstack/react-router"
import { AlertTriangle, CornerDownLeft } from "lucide-react"
import { useMC, usePaletteOffen } from "@/app/store"
import { api } from "@/lib/api"
import { cn } from "@/lib/utils"
import {
GRUPPEN_TITEL, befehle, suchtext,
type Befehl, type BefehlsGruppe,
} from "@/app/palette/register"
// Befehlspalette 2.0 (v3-Umbau P6, Spezifikation §4.6).
//
// Vorher listete sie die elf Navigations-Einträge — ein Sprungbrett. Jetzt tippt man,
// WAS man will, nicht wo es liegt: springen, tun, finden, oder den Text direkt als Idee
// an Lucy geben. Das Register steht in app/palette/register.ts.
//
// Die Rückfrage ist keine Höflichkeit, sondern Absicht: Die Palette ist schnell genug,
// dass „Strg+K, mot, Enter" den Motor neu startet, bevor man es zu Ende gedacht hat.
// Befehle mit `rueckfrage` halten deshalb an und sagen erst, was passieren wird.
const GRUPPEN_REIHE: BefehlsGruppe[] = ["gehe", "tun", "finden", "lucy"]
export function CommandPalette() {
const offen = usePaletteOffen()
const palette = useMC((s) => s.palette)
const melden = useMC((s) => s.melden)
const navigate = useNavigate()
const [text, setText] = useState("")
const [nachfrage, setNachfrage] = useState<Befehl | null>(null)
const [laeuft, setLaeuft] = useState(false)
const schliessen = useCallback(() => {
palette(false)
setText("")
setNachfrage(null)
}, [palette])
const ausfuehren = useCallback(async (b: Befehl) => {
setLaeuft(true)
try {
await b.ausfuehren({
text,
navigate: (pfad) => navigate({ to: pfad }),
schublade: (tab) => navigate({ to: ".", search: (a) => ({ ...a, system: tab }) }),
melden,
api,
})
schliessen()
} catch (e) {
// Der echte Grund aus ApiError.detail (P1/B-14) — nicht „Fehler".
melden("fehler", e instanceof Error ? e.message : String(e))
setNachfrage(null)
} finally {
setLaeuft(false)
}
}, [text, navigate, melden, schliessen])
const liste = useMemo(() => befehle(text), [text])
return (
<Command.Dialog
open={offen}
onOpenChange={(o) => (o ? palette(true) : schliessen())}
label="Befehlspalette"
className="fixed inset-0 z-50 flex items-start justify-center overscroll-contain bg-black/50 p-4 pt-[12vh]"
onClick={schliessen}
shouldFilter={!nachfrage}
>
<div
className="w-full max-w-xl overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{nachfrage ? (
// ── Zweistufige Rückfrage ────────────────────────────────────────
<div className="space-y-4 p-5">
<div className="flex items-center gap-2.5">
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
<h2 className="font-space text-sm font-bold tracking-tight">{nachfrage.label}</h2>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">{nachfrage.rueckfrage}</p>
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setNachfrage(null)}
className="h-9 cursor-pointer rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
>
Abbrechen
</button>
<button
autoFocus
disabled={laeuft}
onClick={() => ausfuehren(nachfrage)}
className="h-9 cursor-pointer rounded-lg bg-amber-500/90 px-4 text-xs font-bold text-black transition-colors hover:bg-amber-500 disabled:opacity-50"
>
{laeuft ? "Läuft …" : "Ja, ausführen"}
</button>
</div>
</div>
) : (
<>
<Command.Input
autoFocus
value={text}
onValueChange={setText}
placeholder="Springen, tun, finden — oder schreib eine Idee …"
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<Command.List className="max-h-[26rem] overflow-y-auto p-2 scrollbar-thin">
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
Nichts gefunden.
</Command.Empty>
{GRUPPEN_REIHE.map((gruppe) => {
const eintraege = liste.filter((b) => b.gruppe === gruppe)
if (!eintraege.length) return null
return (
<Command.Group
key={gruppe}
heading={GRUPPEN_TITEL[gruppe]}
className="px-1 py-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50"
>
{eintraege.map((b) => (
<Command.Item
key={b.id}
value={suchtext(b)}
onSelect={() => (b.rueckfrage ? setNachfrage(b) : ausfuehren(b))}
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<b.icon
className={cn("h-4 w-4 shrink-0", b.rueckfrage ? "text-amber-400" : "text-primary")}
aria-hidden="true"
/>
<span className="truncate">{b.label}</span>
{b.rueckfrage && (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-amber-300">
fragt nach
</span>
)}
{b.hinweis && (
<span className="ml-auto truncate pl-3 text-xs text-muted-foreground">{b.hinweis}</span>
)}
</Command.Item>
))}
</Command.Group>
)
})}
</Command.List>
<div className="flex items-center gap-3 border-t border-border/50 px-4 py-2 font-mono text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">↑↓ wählen</span>
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" aria-hidden="true" /> ausführen</span>
<span className="ml-auto">esc schließt</span>
</div>
</>
)}
</div>
</Command.Dialog>
)
}
-84
View File
@@ -1,84 +0,0 @@
import { useRef } from "react"
import { X } from "lucide-react"
export interface CustomDialogProps {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
autoValue?: string
autoLabel?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function CustomDialog({ type, title, message, defaultValue, autoValue, autoLabel, onConfirm, onCancel }: CustomDialogProps) {
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className="fixed inset-0 z-[99] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={title}>
<div className="w-full max-w-sm 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">{title}</h3>
<button
onClick={onCancel || (() => onConfirm())}
aria-label="Schließen"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{/* whitespace-pre-line + Scroll: mehrzeilige Botschaften (z. B. Dubletten-Vorschau,
Annehmen-Warnungen) sauber rendern statt zu einer Wurst zusammenzufallen. */}
<p className="max-h-72 overflow-y-auto whitespace-pre-line text-xs text-muted-foreground leading-relaxed scrollbar-thin">{message}</p>
{type === "prompt" && (
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
defaultValue={defaultValue}
aria-label={title}
className="flex-1 h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
onConfirm(inputRef.current?.value)
}
}}
/>
{autoValue !== undefined && (
<button
type="button"
onClick={() => { if (inputRef.current) inputRef.current.value = autoValue }}
title="Setup-bewussten Optimalwert eintragen"
className="h-9 px-3 shrink-0 text-xs font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors cursor-pointer"
>
{autoLabel || "Auto"}
</button>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
{(type === "confirm" || type === "prompt") && (
<button
onClick={onCancel}
className="h-8 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"
>
Abbrechen
</button>
)}
<button
onClick={() => {
const val = type === "prompt" ? inputRef.current?.value : undefined
onConfirm(val)
}}
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/95 rounded-lg transition-colors cursor-pointer"
>
{type === "confirm" ? "Ja, fortfahren" : type === "prompt" ? "Übernehmen" : "OK"}
</button>
</div>
</div>
</div>
)
}
-38
View File
@@ -1,38 +0,0 @@
import { Sparkles, SlidersHorizontal } from "lucide-react"
import { useExpertenmodus, useMC } from "@/app/store"
import { cn } from "@/lib/utils"
// Zwei-Zustand-Pille im Header: „Einfach" (aufgeräumt) ↔ „Experte" (alle Details).
export function ExpertToggle() {
const expert = useExpertenmodus()
const setExpertMode = useMC((s) => s.expertenmodus)
return (
<div
className="flex items-center rounded-md border border-border/40 bg-background/40 p-0.5"
role="group"
aria-label="Ansichtsmodus"
title="Einfach zeigt nur das Wichtigste. Experte zeigt alle technischen Details."
>
<button
onClick={() => setExpertMode(false)}
aria-pressed={!expert}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
!expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
<Sparkles className="h-3.5 w-3.5" /> Einfach
</button>
<button
onClick={() => setExpertMode(true)}
aria-pressed={expert}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" /> Experte
</button>
</div>
)
}
-61
View File
@@ -1,61 +0,0 @@
import { AlertTriangle, RotateCcw } from "lucide-react"
// Fehlerfläche EINER Ansicht (v3-Umbau P2, Befund B-13).
//
// Vorher hing genau eine Fehlergrenze in main.tsx um die gesamte App: Ein Renderfehler
// im Modell-Manager nahm Cockpit, Konsole und Chronik gleich mit — die Seite war weiß.
// Jetzt fängt jede Route für sich; Schiene, Kopfzeile und Statusanzeige bleiben stehen,
// und der Nutzer kann einfach woanders hin.
//
// Die Meldung sagt, was passiert ist und was man tun kann. Der technische Text steht
// aufklappbar darunter — nicht versteckt (man braucht ihn für einen Fehlerbericht),
// aber auch nicht als Erstes im Gesicht.
export function RouteFehler({ titel, text, fehler, onErneut }: {
titel: string
text: string
fehler?: unknown
onErneut?: () => void
}) {
const detail = fehler instanceof Error ? (fehler.stack || fehler.message) : fehler ? String(fehler) : null
return (
<div className="flex min-h-[50vh] items-center justify-center p-6">
<div className="mc-card max-w-lg space-y-4 p-6">
<div className="flex items-center gap-2.5">
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
<h1 className="font-space text-lg font-bold tracking-tight">{titel}</h1>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
<div className="flex flex-wrap gap-2 pt-1">
{onErneut && (
<button
onClick={onErneut}
className="flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-primary px-4 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> Erneut versuchen
</button>
)}
<a
href="/cockpit"
className="flex h-9 items-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
>
Zum Cockpit
</a>
</div>
{detail && (
<details className="pt-1">
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground">
Technische Einzelheiten
</summary>
<pre className="scrollbar-thin mt-2 max-h-52 overflow-auto rounded-lg border border-border/40 bg-background/40 p-3 font-mono text-[10px] leading-relaxed text-muted-foreground">
{detail}
</pre>
</details>
)}
</div>
</div>
)
}
-576
View File
@@ -1,576 +0,0 @@
import { useEffect, useState } from "react"
import { X, RefreshCw, Cpu, Server, Shield, Power, Camera, Bot, Box, Shuffle, DownloadCloud } from "lucide-react"
import { api, type UpdateDetails } from "@/lib/api"
import { cn } from "@/lib/utils"
import { useDialog } from "@/lib/useDialog"
import { useJobs, useServices, useUpdates, useZeitmaschine, useQueryClient, qk, invalidate } from "@/lib/queries"
import { fmtBytes } from "@/lib/format"
import { ServiceRow, UpdateRow } from "./system/rows"
import { LogConsole } from "./system/LogConsole"
import { SettingsTab } from "./system/SettingsTab"
import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal"
// System-Schublade (Pflege/Logs/Einstellungen). Datenweg: AUSSCHLIESSLICH die zentralen
// React-Query-Hooks — früher pollte hier ein eigener setInterval alle 3 s dieselben
// Endpunkte (inkl. des teuren Updates-Checks) am Query-Layer vorbei, wodurch Cockpit
// und Schublade verschiedene Stände zeigen konnten (Review 15.07.). Die Dienste-Liste
// kommt 1:1 aus /api/system/services (unit + scope), keine UI-Kopie mehr.
interface SystemDrawerProps {
open: boolean
onClose: () => void
defaultTab?: "maintenance" | "logs" | "settings"
}
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
// Geteilte Caches; Jobs/Zeitmaschine beobachten nur bei offener Schublade.
const { data: updates } = useUpdates()
const { data: services } = useServices()
const jobs = useJobs(3_000, open).data ?? []
const { data: zeit } = useZeitmaschine(undefined, open)
const [selectedService, setSelectedService] = useState("llama-swap")
const [logs, setLogs] = useState("")
const [loadingLogs, setLoadingLogs] = useState(false)
const [logStatus, setLogStatus] = useState<string | null>(null)
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<"maintenance" | "logs" | "settings">("maintenance")
const [checkingUpdates, setCheckingUpdates] = useState(false)
const [backupMsg, setBackupMsg] = useState("")
const [backupRunning, setBackupRunning] = useState(false)
const [hermesUpdating, setHermesUpdating] = useState(false)
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
const [detail, setDetail] = useState<UpdateDetailState | null>(null)
const svcList = services?.services ?? []
const refreshJobs = () => invalidate(qc, qk.jobs)
function formatLastCheck(ts?: number | null) {
if (!ts) return "Nie"
return new Date(ts * 1000).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
})
}
useEffect(() => {
if (open && defaultTab) setActiveTab(defaultTab)
}, [open, defaultTab])
// Beim Öffnen einmal frisch ziehen — danach übernehmen die normalen Poll-Takte.
useEffect(() => {
if (open) invalidate(qc, qk.updates, qk.services, qk.jobs, qk.zeitmaschine)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
function loadServiceLogs(service: string) {
setLoadingLogs(true)
setLogStatus(null)
api<{ ok: boolean; text: string; err?: string; status?: string }>(`/api/maintenance/logs?service=${service}&lines=150`)
.then((res) => {
if (res.ok) {
setLogs(res.text)
} else {
setLogs(`Fehler beim Laden der Logs: ${res.err || "Unbekannter Fehler"}`)
if (res.status === "incorrect_password" || res.status === "password_required") {
setLogStatus(res.status)
}
}
})
.catch((e) => setLogs(`Fehler: ${e.message}`))
.finally(() => setLoadingLogs(false))
}
// Load logs when tab is active or service changes
useEffect(() => {
if (!open || activeTab !== "logs") return
loadServiceLogs(selectedService)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeTab, selectedService])
// Backend-Wartungsriegel: lehnt ein zweites Update ab, solange eines läuft.
function handledBusy(res: any): boolean {
if (res?.status === "busy") {
showAlert("Update läuft bereits", `Es läuft gerade „${res.running}". Bitte warte, bis es fertig ist.`)
refreshJobs()
return true
}
return false
}
// Passwort-Fehler NICHT verschlucken: OS-Update braucht das Box-Passwort.
function handledSudo(res: any): boolean {
if (res?.status === "password_required" || res?.status === "incorrect_password") {
showAlert(
"Box-Passwort nötig",
(res.status === "incorrect_password"
? "Das gespeicherte Box-Passwort stimmt nicht. "
: "Für dieses Update braucht die Box dein Passwort. ")
+ "Bitte oben im Reiter „Einstellungen“ setzen bzw. korrigieren.",
)
return true
}
if (res && res.ok === false) {
showAlert("Update fehlgeschlagen", res.err || "Unbekannter Fehler.")
return true
}
return false
}
async function triggerUpdate(kind: "os" | "engine" | "swap") {
const path = { os: "os-update", engine: "engine-update", swap: "swap-update" }[kind]
try {
const res = await api<any>(`/api/maintenance/${path}`, { method: "POST" })
if (handledBusy(res) || handledSudo(res)) return
refreshJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Update konnte nicht starten: ${e.message}`)
}
}
async function doHermesUpdate() {
setHermesUpdating(true)
try {
const res = await api<any>("/api/maintenance/hermes-update", { method: "POST" })
if (handledBusy(res)) return
refreshJobs()
} catch (e: any) {
showAlert("Fehler", `Hermes-Update fehlgeschlagen: ${e.message}`)
} finally {
setHermesUpdating(false)
}
}
// Öffnet das Detail-Fenster und lädt, was genau aktualisiert würde.
async function openUpdateDetails(kind: "os" | "engine" | "swap" | "hermes") {
setDetail({ kind, loading: true, data: null })
try {
const d = await api<UpdateDetails>(`/api/maintenance/update-details?kind=${kind}`)
setDetail({ kind, loading: false, data: d })
} catch (e: any) {
setDetail({ kind, loading: false, data: { kind, error: e.message } })
}
}
// Bestätigung aus dem Detail-Fenster → startet das passende Update.
function applyFromDetail() {
const kind = detail?.kind
setDetail(null)
if (kind === "os" || kind === "engine" || kind === "swap") triggerUpdate(kind)
else if (kind === "hermes") doHermesUpdate()
}
// „Alle aktualisieren": kettet alle AUSSTEHENDEN Updates in EINEM Wartungs-Job
// (Engine → Router → Hermes → OS; bei einem Fehler stoppt die Kette).
function triggerUpdateAll() {
const teile = [
updates?.engine ? "Engine" : null,
updates?.swap ? "Router" : null,
updates?.components?.some((c) => c.update === true) ? "Hermes-Agent" : null,
updates?.os ? `OS (${updates.os} Pakete)` : null,
].filter(Boolean)
showConfirm(
"Alle Updates einspielen?",
`Nacheinander in einem Job: ${teile.join(" → ")}. Jeder Schritt sichert und prüft sich selbst; schlägt einer fehl, stoppt der Rest. (OS braucht das Box-Passwort aus den Einstellungen — fehlt es, wird OS übersprungen.)`,
async () => {
try {
const res = await api<any>("/api/maintenance/update-all", { method: "POST" })
if (handledBusy(res) || handledSudo(res)) return
if (res?.status === "nothing") { showAlert("Nichts zu tun", "Es stehen keine Updates aus."); return }
refreshJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `„Alle aktualisieren" konnte nicht starten: ${e.message}`)
}
},
)
}
async function triggerCheckUpdates() {
setCheckingUpdates(true)
try {
await api("/api/maintenance/check-updates", { method: "POST" })
invalidate(qc, qk.jobs, qk.updates)
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Fehler bei der Update-Suche: ${e.message}`)
} finally {
setCheckingUpdates(false)
}
}
async function triggerModelUpgrade(repo: string, role: string) {
try {
await api("/api/models/install", { method: "POST", body: JSON.stringify({ repo, role }) })
showAlert("Gestartet", `Modell-Upgrade für '${role}' (${repo}) gestartet.`)
refreshJobs()
setActiveTab("maintenance")
} catch (e: any) {
showAlert("Fehler", `Fehler beim Starten des Modell-Upgrades: ${e.message}`)
}
}
async function triggerReboot() {
showConfirm(
"Reboot bestätigen",
"Bist du sicher, dass du das gesamte Host-System neu starten willst?",
async () => {
try {
await api("/api/maintenance/reboot", { method: "POST" })
showAlert("Reboot", "Reboot ausgelöst. System startet neu...", () => onClose())
} catch (e: any) {
showAlert("Fehler", `Fehler beim Reboot: ${e.message}`)
}
}
)
}
async function doBackup() {
setBackupRunning(true)
setBackupMsg("Snapshot wird erzeugt...")
try {
const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" })
setBackupMsg(r.ok ? `Snapshot erzeugt: ${r.snapshot} (${r.files.length} Komponenten)` : "Backup fehlgeschlagen.")
invalidate(qc, qk.zeitmaschine)
} catch (e: any) {
setBackupMsg(`Fehler: ${e.message}`)
} finally {
setBackupRunning(false)
}
}
async function restartService(serviceId: string) {
setRestartingServices(prev => ({ ...prev, [serviceId]: true }))
try {
const res = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: serviceId })
})
if (res.ok) {
showAlert("Dienst neu gestartet", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`, () => {
if (activeTab === "logs" && selectedService === serviceId) {
loadServiceLogs(serviceId)
}
})
invalidate(qc, qk.services, qk.health, qk.models)
} else {
showAlert("Fehler", `Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`)
}
} catch (e: any) {
showAlert("Fehler", `Fehler beim Neustart: ${e.message}`)
} finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
}
}
async function cancelJob(jobId: string) {
try {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
refreshJobs()
} catch (e: any) {
showAlert("Fehler", `Fehler beim Abbrechen: ${e.message}`)
}
}
// Aktiver Wartungs-Job (System-Update) → UI-Aktionen sperren, Dashboard bleibt sichtbar.
const maintenanceJob = jobs.find(j => (j.state === "running" || j.state === "queued") && j.group === "maintenance")
// Wie viele Update-ARTEN ausstehen (für den „Alle aktualisieren"-Button).
const pendingAll =
(updates ? (updates.os > 0 ? 1 : 0) + (updates.engine ? 1 : 0) + (updates.swap ? 1 : 0) : 0) +
(updates?.components?.filter((c) => c.update === true).length ?? 0)
const backups = zeit?.backups ?? []
return (
<>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 bg-black/60 backdrop-blur-sm z-50 transition-opacity duration-300",
open ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
/>
{/* Drawer */}
<div
className={cn(
"fixed inset-y-0 right-0 w-full sm:w-[640px] bg-card/85 backdrop-blur-xl border-l border-border/60 shadow-2xl z-50 flex flex-col transform transition-transform duration-300 ease-in-out font-sans text-foreground",
open ? "translate-x-0" : "translate-x-full"
)}
>
{/* Header */}
<div className="flex h-16 shrink-0 items-center justify-between border-b border-border/40 px-6">
<div className="flex items-center gap-2">
<Cpu className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase font-space">OS-Zentrale & Pflege</h2>
</div>
<button
onClick={onClose}
aria-label="System-Schublade schließen"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors cursor-pointer"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border/40 px-6">
{(["maintenance", "logs", "settings"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
activeTab === tab
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab === "maintenance" ? "System-Wartung" : tab === "logs" ? "System-Logs" : "Einstellungen"}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{activeTab === "maintenance" && (
<>
{/* Updates */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Updates</h3>
<div className="flex items-center gap-3">
{pendingAll > 0 && (
<button onClick={triggerUpdateAll} disabled={!!maintenanceJob}
className="flex items-center gap-1 rounded-md border border-primary/40 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary transition-colors hover:bg-primary/20 disabled:opacity-50">
<DownloadCloud className="h-3 w-3" /> Alle aktualisieren ({pendingAll})
</button>
)}
<button onClick={triggerCheckUpdates} disabled={checkingUpdates || !!maintenanceJob}
className="flex items-center gap-1 text-[10px] font-semibold text-primary hover:text-primary/80 transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3 w-3", checkingUpdates && "animate-spin")} /> Nach Updates suchen
</button>
</div>
</div>
{updates?.last_check && (
<div className="text-[9px] text-muted-foreground -mt-1">Zuletzt gesucht: {formatLastCheck(updates.last_check)}</div>
)}
{maintenanceJob && (
<div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin" />
<span>Update läuft: <span className="font-semibold">{maintenanceJob.label}</span> — bitte warten. Weitere Updates sind solange gesperrt.</span>
</div>
)}
<div className="space-y-1.5">
<UpdateRow icon={Shield} iconClass="text-cyan-400" name="OS-Pakete (apt)" available={!!updates?.os} status={updates?.os ? `${updates.os} verfügbar` : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("os")} />
<UpdateRow icon={Server} iconClass="text-violet-400" name="Inferenz-Engine (llama.cpp)" available={!!updates?.engine} status={updates?.engine ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("engine")} />
<UpdateRow icon={Shuffle} iconClass="text-fuchsia-400" name="Router (llama-swap)" available={!!updates?.swap} status={updates?.swap ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("swap")} />
{(() => {
const h = updates?.components?.find((c) => c.key === "hermes_agent")
return (
<UpdateRow icon={Bot} iconClass="text-amber-400" name="Hermes-Agent" available={h?.update === true} busy={hermesUpdating} status={h?.update === true ? `Update: ${h.latest}` : h?.reachable === false ? "offline" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("hermes")} />
)
})()}
{updates?.model_list?.map((m) => (
<UpdateRow key={m.role} icon={Box} iconClass="text-emerald-400" name={`Modell · ${m.role}`} available={true} status={m.title} actionLabel="Upgrade" onAction={() => triggerModelUpgrade(m.repo, m.role)} />
))}
</div>
</div>
{/* Dienste — 1:1 die Backend-Liste (inkl. LLM-Gateway + Wächter seit UMBAU v3) */}
<div className="space-y-2.5">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Dienste</h3>
<div className="space-y-1.5">
{svcList.length === 0 && (
<div className="rounded-lg border border-dashed border-border/50 p-4 text-center text-xs text-muted-foreground">
Dienste-Liste wird geladen …
</div>
)}
{svcList.map((s) => (
<ServiceRow key={s.unit} label={s.name} system={s.scope === "system"} ok={s.ok}
busy={restartingServices[s.unit]}
onRestart={() => restartService(s.unit)}
onLogs={() => { setSelectedService(s.unit); setActiveTab("logs") }} />
))}
</div>
</div>
{/* Backup — Liste kommt aus derselben Quelle wie die Zeitmaschine (Chronik) */}
<div className="space-y-2.5">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Backup</h3>
<div className="rounded-lg border border-border/50 bg-background/20 px-3 py-2.5 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{backups[0] ? `Letztes: ${backups[0].snapshot}` : "Noch kein Backup"}</div>
<div className="text-[10px] text-muted-foreground">{backups.length} Snapshots · Wiederherstellen: Chronik → Zeitmaschine</div>
</div>
<button onClick={doBackup} disabled={backupRunning} className="flex items-center gap-1.5 text-[11px] font-semibold rounded-lg px-2.5 py-1 bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-50 shrink-0">
<Camera className={cn("h-3.5 w-3.5", backupRunning && "animate-pulse")} /> Snapshot
</button>
</div>
{backupMsg && (
<div className="text-[10px] font-mono text-primary bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">{backupMsg}</div>
)}
</div>
{/* Gefahrenzone */}
<div className="space-y-2.5">
<h3 className="text-xs font-bold uppercase tracking-wider text-red-400/80">Gefahrenzone</h3>
<button onClick={triggerReboot} className="flex w-full items-center gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 text-red-400 transition-all text-left text-xs font-semibold">
<Power className="h-4.5 w-4.5" />
<div>
<div>Host-System neu starten</div>
<div className="text-[10px] text-red-400/80 font-normal">Startet die ganze Box neu</div>
</div>
</button>
</div>
{/* Active Jobs */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Hintergrund-Aufgaben</h3>
<span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded-full">
{jobs.filter(j => j.state === "running" || j.state === "queued").length} Aktiv
</span>
</div>
<div className="space-y-3">
{jobs.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-xl p-6 text-center">
Aktuell keine aktiven Hintergrund-Jobs.
</div>
) : (
jobs.map((job) => {
const isActive = job.state === "running" || job.state === "queued"
return (
<div
key={job.id}
className={cn(
"p-3 rounded-xl border transition-all duration-300",
isActive
? "border-primary/40 bg-primary/5 shadow-md shadow-primary/5"
: "border-border/40 bg-background/20 opacity-80"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-xs font-semibold flex items-center gap-1.5">
{isActive && (
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
)}
{job.label}
</div>
<div className="text-[10px] font-mono text-muted-foreground uppercase flex items-center gap-2">
<span>ID: {job.id}</span>
<span>•</span>
<span className={cn(
job.state === "done" && "text-emerald-400",
job.state === "failed" && "text-red-400",
job.state === "running" && "text-primary",
job.state === "queued" && "text-amber-400",
job.state === "canceled" && "text-muted-foreground"
)}>
{job.state}
</span>
</div>
</div>
{isActive && (
<button
onClick={() => cancelJob(job.id)}
className="text-[10px] font-semibold text-red-400 hover:text-red-300 border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 px-2 py-0.5 rounded transition-colors"
>
Abbrechen
</button>
)}
</div>
{/* Progress */}
{job.state === "running" && (
<div className="mt-3 space-y-1">
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${job.progress ?? 0}%` }}
/>
</div>
<div className="flex justify-between items-center text-[9px] font-mono text-muted-foreground">
<span>{job.progress ?? 0}%</span>
{job.done_bytes != null && job.total_bytes != null && (
<span>
{fmtBytes(job.done_bytes)} / {fmtBytes(job.total_bytes)}
{job.rate_bps != null && ` (${fmtBytes(job.rate_bps)}/s)`}
</span>
)}
{job.eta_s != null && <span>ETA: {job.eta_s}s</span>}
</div>
</div>
)}
</div>
)
})
)}
</div>
</div>
</>
)}
{activeTab === "logs" && (
// Logs View
<div className="flex flex-col h-full space-y-4">
{/* Service Select & Restart — dieselbe Backend-Liste wie die Wartungs-Ansicht */}
<div className="flex items-center gap-2">
<select
value={selectedService}
onChange={(e) => setSelectedService(e.target.value)}
className="flex-1 h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none text-foreground font-semibold"
>
{svcList.map((s) => (
<option key={s.unit} value={s.unit}>
{s.name} ({s.scope === "system" ? "systemd-root" : "user"})
</option>
))}
</select>
<button
onClick={() => restartService(selectedService)}
disabled={restartingServices[selectedService]}
className="flex h-9 px-3 items-center gap-1.5 text-xs font-semibold rounded-lg border border-border/60 bg-background/20 hover:border-primary/50 transition-colors disabled:opacity-50"
title="Dienst neu starten"
>
<RefreshCw className={cn("h-3.5 w-3.5", restartingServices[selectedService] && "animate-spin")} />
Restart
</button>
</div>
<LogConsole
service={selectedService}
text={logs}
loading={loadingLogs}
logStatus={logStatus}
onRefresh={() => loadServiceLogs(selectedService)}
onOpenSettings={() => setActiveTab("settings")}
/>
</div>
)}
{activeTab === "settings" && <SettingsTab open={open} showAlert={showAlert} />}
</div>
</div>
{detail && (
<UpdateDetailModal detail={detail} maintenanceJob={maintenanceJob}
onClose={() => setDetail(null)} onApply={applyFromDetail} />
)}
{dialogElement}
</>
)
}
@@ -0,0 +1,37 @@
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog"
/** Rückfrage vor Schritten, die die Box umbauen (alles aktualisieren, zurückspielen, neu starten). */
export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, children }: {
offen: boolean
titel: string
text: string
knopf: string
gefahr?: boolean
onJa: () => void
onNein: () => void
children?: ReactNode
}) {
return (
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
<DialogContent className="border-linie bg-panel sm:max-w-lg">
<DialogHeader>
<DialogTitle className="schild text-xl">{titel}</DialogTitle>
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
</DialogHeader>
{children}
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={onNein}>
Abbrechen
</Button>
<Button variant={gefahr ? "gefahr" : "default"} onClick={onJa}>
{knopf}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,97 @@
import { lazy, Suspense, useState } from "react"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { useHinweisAktion } from "@/lib/abfragen"
import { post } from "@/lib/api"
import { melden } from "@/lib/meldungen"
import type { AktionsErgebnis, Hinweis, VerlaufEintrag } from "@/lib/typen"
import { seit, vor } from "@/lib/zeit"
// Erst laden, wenn jemand ein Protokoll öffnet (Radix-Dialog gehört nicht ins Start-Bündel).
const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster })))
/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */
function Punkt({ h }: { h: Hinweis }) {
const aktion = useHinweisAktion()
const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null)
const rot = h.stufe === "rot"
async function ausloesen(aid: string, label: string) {
if (aid === "protokoll") {
try {
const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`)
setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." })
} catch (f) {
melden("fehler", (f as Error).message)
}
return
}
aktion.mutate({ hinweis: h.id, aktion: aid, label })
}
return (
<article className="flex flex-col gap-2.5 border-t border-linie pt-4">
<div className="flex items-center gap-3">
<h3 className="min-w-0 font-sans text-[17px] leading-snug font-semibold">{h.titel}</h3>
<span aria-hidden className="hidden h-0 flex-grow border-b-2 border-dotted border-linie-stark sm:block" />
<span className={cn("schild shrink-0 text-base", rot ? "text-rot" : "text-bernstein")}>
{rot ? "Jetzt" : "Prüfen"}
</span>
</div>
<p className="ziffern text-[13px] text-text-3">
{seit(h.seit)} · zuletzt {vor(h.zuletzt)}
</p>
{h.text && <p className="text-[15px] leading-relaxed break-words text-text-2">{h.text}</p>}
{h.aktionen.length > 0 && (
<div className="flex flex-wrap gap-2.5 pt-1">
{h.aktionen.map((a, i) => (
<Button
key={a.id}
variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"}
size="sm"
disabled={aktion.isPending}
onClick={() => ausloesen(a.id, a.label)}
>
{a.label}
</Button>
))}
</div>
)}
{protokoll && (
<Suspense fallback={null}>
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} />
</Suspense>
)}
</article>
)
}
export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf: VerlaufEintrag[] }) {
const heute = new Date().toDateString()
const erledigtHeute = verlauf.filter(
(v) => (v.art === "erledigt" || v.art === "auto") && new Date(v.ts * 1000).toDateString() === heute,
)
if (hinweise.length === 0) {
return (
<div className="flex flex-col gap-2 border-t border-linie pt-4">
<p className="text-[15px] text-text-2">Keine offenen Punkte. Der Wächter prüft jede Minute.</p>
{erledigtHeute.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-text-3">
{erledigtHeute.slice(0, 4).map((v) => (
<li key={`${v.id}-${v.ts}`}>
<span className="ziffern">{vor(v.ts)}</span> · {v.text}
</li>
))}
</ul>
)}
</div>
)
}
return (
<div className="flex flex-col gap-4">
{hinweise.map((h) => (
<Punkt key={h.id} h={h} />
))}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react"
import { cn } from "cn"
export type ChipArt = "gruen" | "bernstein" | "cyan" | "rot" | "grau"
const ART: Record<ChipArt, string> = {
gruen: "border-gruen-rand bg-gruen-grund text-gruen",
bernstein: "border-bernstein/60 bg-bernstein-grund text-bernstein",
cyan: "border-cyan-rand bg-cyan-grund text-cyan",
rot: "border-rot-rand bg-rot-grund text-rot-text",
grau: "border-linie bg-erhaben text-text-2",
}
/** Kleines Zustandsschild („GELADEN“, „PASST“, „FEHLGESCHLAGEN“). */
export function Chip({ art = "grau", children }: { art?: ChipArt; children: ReactNode }) {
return (
<span className={cn("schild inline-flex h-7 items-center rounded-full border px-2.5 text-xs whitespace-nowrap", ART[art])}>
{children}
</span>
)
}
/** Einheitlicher Platzhalter, solange Daten laden oder wenn die Box nicht antwortet. */
export function Zustandsfeld({ fehler, text }: { fehler?: boolean; text: string }) {
return (
<div
role={fehler ? "alert" : "status"}
className={cn(
"rounded-2xl border px-5 py-8 text-center text-[15px]",
fehler ? "border-rot-rand bg-rot-grund text-rot-text" : "border-linie bg-panel text-text-2",
)}
>
{text}
</div>
)
}
@@ -0,0 +1,31 @@
import { cn } from "cn"
import type { FlugplanEintrag } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }> = {
erledigt: { text: "Erledigt", farbe: "text-gruen" },
fehler: { text: "Fehler", farbe: "text-rot" },
geplant: { text: "Geplant", farbe: "text-text-3" },
}
function Zeile({ e }: { e: FlugplanEintrag }) {
const s = STATUS[e.status] ?? STATUS.geplant
// Updates sind das Wichtigste im Plan — sie stehen in Cyan.
const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan" : s.farbe
return (
<li className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5">
<span className="ziffern text-sm text-text-3">{flugplanZeit(e.zeit)}</span>
<span className="min-w-0 text-base">
{e.titel}
{e.text && <span className="text-text-3">, {e.text}</span>}
</span>
<span className={cn("schild text-[13px]", farbe)}>{s.text}</span>
</li>
)
}
export function Flugplan({ gelaufen, geplant }: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }) {
const alle = [...gelaufen, ...geplant.slice(0, 5)]
if (alle.length === 0) return <p className="text-[15px] text-text-2">Heute ist noch nichts gelaufen, und nichts ist geplant.</p>
return <ol className="m-0 flex list-none flex-col p-0">{alle.map((e) => <Zeile key={`${e.titel}-${e.zeit}`} e={e} />)}</ol>
}
@@ -0,0 +1,50 @@
// Rundinstrument: 240°-Bogen wie ein Zeigerinstrument, Wert als Leuchtbogen, Zahl in der Mitte.
import { bogenPfad } from "@/lib/anzeige"
export interface Zone {
von: number
bis: number
farbe: string
}
export function Instrument({
anteil,
farbe = "var(--cyan)",
zonen = [],
mitte,
unter,
beschriftung,
ariaText,
}: {
anteil: number | null
farbe?: string
zonen?: Zone[]
mitte: string
unter?: string
beschriftung: string
ariaText: string
}) {
const zeigen = anteil != null && anteil > 0.004
return (
<figure className="m-0 flex min-w-0 flex-col items-center gap-1">
<svg viewBox="0 0 200 150" className="h-auto w-full max-w-[200px]" role="img" aria-label={ariaText}>
<path d={bogenPfad(0, 1, 75)} fill="none" stroke="var(--linie)" strokeWidth={12} strokeLinecap="round" />
{zonen.map((z) => (
<path key={`${z.von}-${z.bis}`} d={bogenPfad(z.von, z.bis, 86)} fill="none" stroke={z.farbe} strokeWidth={4} strokeLinecap="round" />
))}
{zeigen && (
<path d={bogenPfad(0, anteil, 75)} fill="none" stroke={farbe} strokeWidth={12} strokeLinecap="round" />
)}
<text x="100" y="100" textAnchor="middle" className="ziffern" style={{ fontSize: 34, fontWeight: 500, fill: "var(--foreground)" }}>
{mitte}
</text>
{unter && (
<text x="100" y="122" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 14, fontWeight: 600, letterSpacing: "0.12em", fill: "var(--text-3)" }}>
{unter.toUpperCase()}
</text>
)}
</svg>
<figcaption className="schild text-[15px] text-text-3">{beschriftung}</figcaption>
</figure>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { cn } from "cn"
import type { Lampe as LampenDaten, LampenZustand } from "@/lib/typen"
// Warnlampen wie im Flugzeug: Grün = läuft, dunkel = aus/auf Abruf, Bernstein = kümmern,
// Cyan = Info, Rot = Störung. Bernstein und Rot leuchten, alles andere bleibt ruhig.
const LAMPEN_STIL: Record<LampenZustand, { feld: string; name: string; wert: string }> = {
ok: { feld: "border-gruen-rand bg-gruen-grund", name: "text-gruen", wert: "text-gruen-text" },
aus: { feld: "border-aus-rand bg-aus-grund", name: "text-aus-text", wert: "text-text-3" },
warn: { feld: "border-bernstein bg-bernstein-lampe lampe-atmet", name: "text-bernstein", wert: "text-[#f2c46b]" },
info: { feld: "border-cyan-rand bg-cyan-grund", name: "text-cyan", wert: "text-cyan-text" },
fehler: {
feld: "border-rot bg-rot-grund shadow-[0_0_18px_rgba(255,90,79,0.35)]",
name: "text-rot",
wert: "text-rot-text",
},
}
const ZUSTAND_TEXT: Record<LampenZustand, string> = {
ok: "in Ordnung",
aus: "aus",
warn: "braucht Aufmerksamkeit",
info: "Info",
fehler: "Störung",
}
export function Lampe({ lampe }: { lampe: LampenDaten }) {
const stil = LAMPEN_STIL[lampe.zustand] ?? LAMPEN_STIL.aus
return (
<li
className={cn(
"flex min-h-[58px] flex-col items-center justify-center gap-0.5 rounded-lg border px-2 py-2 text-center",
stil.feld,
)}
>
<span className={cn("schild text-[17px] leading-none font-bold tracking-[0.18em]", stil.name)}>
{lampe.label}
</span>
<span className={cn("ziffern text-xs uppercase", stil.wert)}>{lampe.wert}</span>
<span className="sr-only">{ZUSTAND_TEXT[lampe.zustand]}</span>
</li>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from "react"
import { cn } from "cn"
/** Ein Feld des Instrumentenbretts: dunkle Platte, schmale Beschriftung oben links. */
export function Panel({
titel,
rechts,
children,
className,
id,
}: {
titel?: string
rechts?: ReactNode
children: ReactNode
className?: string
id?: string
}) {
return (
<section
id={id}
aria-label={titel}
className={cn("flex min-w-0 flex-col gap-4 rounded-2xl border border-linie bg-panel p-5 sm:p-6", className)}
>
{(titel || rechts) && (
<div className="flex flex-wrap items-center justify-between gap-3">
{titel && <h2 className="schild text-lg font-bold tracking-[0.22em] text-foreground">{titel}</h2>}
{rechts}
</div>
)}
{children}
</section>
)
}
@@ -0,0 +1,25 @@
import {
Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,
} from "@/components/ui/sheet"
/** Seitenschublade mit Protokolltext (Journal oder Hermes-Fehlerzeilen), unten zuerst gelesen. */
export function Protokollfenster({ offen, titel, text, onSchliessen }: {
offen: boolean
titel: string
text: string
onSchliessen: () => void
}) {
return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="w-full border-linie bg-panel sm:max-w-2xl">
<SheetHeader>
<SheetTitle className="schild text-lg">Protokoll</SheetTitle>
<SheetDescription className="text-text-2">{titel}</SheetDescription>
</SheetHeader>
<pre className="ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
{text}
</pre>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,47 @@
import { cn } from "cn"
import type { Lampe as LampenDaten, Start } from "@/lib/typen"
import { hauptleuchte } from "@/lib/anzeige"
import { Lampe } from "./Lampe"
type Zustand = Start["zustand"]
const LEUCHTE: Record<ReturnType<typeof hauptleuchte>["art"], string> = {
ok: "border-gruen-rand bg-gruen-grund text-gruen",
info: "border-cyan-rand bg-cyan-grund text-cyan",
stumm: "border-aus-rand bg-aus-grund text-aus-text",
gelb: "border-2 border-bernstein bg-bernstein-grund text-bernstein-hell lampe-atmet",
rot: "border-2 border-rot bg-rot-grund text-rot shadow-[0_0_32px_rgba(255,90,79,0.3)]",
}
export function Warnpanel({ zustand, lampen, onAnsehen }: {
zustand: Zustand
lampen: LampenDaten[]
onAnsehen: () => void
}) {
const h = hauptleuchte(zustand)
const aktiv = h.art === "gelb" || h.art === "rot"
return (
<section aria-label="Warnpanel" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${h.oben} ${h.mitte}`}
className={cn(
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
LEUCHTE[h.art],
aktiv ? "cursor-pointer" : "cursor-default",
)}
>
<span className="schild text-sm tracking-[0.26em]">{h.oben}</span>
<span className="text-[40px] leading-none font-bold tracking-[0.04em] sm:text-[44px]">{h.mitte}</span>
<span className="font-sans text-sm opacity-80">{h.unten}</span>
</button>
<ul className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4">
{lampen.map((l) => (
<Lampe key={l.id} lampe={l} />
))}
</ul>
</section>
)
}
@@ -0,0 +1,30 @@
import { laufzeit } from "@/lib/zeit"
function Ziffer({ z }: { z: string }) {
return (
<span className="ziffern inline-flex h-14 w-[38px] items-center justify-center rounded-md border border-[#2a3036] bg-[#0b0d0f] text-[32px] font-medium">
{z}
</span>
)
}
/** Laufzeit als mechanisches Zählwerk: TT T SS H. */
export function Zaehlwerk({ sekunden }: { sekunden: number | null | undefined }) {
const lz = laufzeit(sekunden)
const tage = String(Math.min(lz?.[0] ?? 0, 99)).padStart(2, "0")
const std = String(lz?.[1] ?? 0).padStart(2, "0")
const text = lz ? `Laufzeit ohne Neustart: ${lz[0]} Tage, ${lz[1]} Stunden` : "Laufzeit unbekannt"
return (
<figure className="m-0 flex min-w-0 flex-col items-center justify-end gap-1">
<div aria-label={text} role="img" className="flex h-[150px] max-w-full items-center gap-1.5 pb-5">
<Ziffer z={lz ? tage[0] : "–"} />
<Ziffer z={lz ? tage[1] : "–"} />
<span className="schild mr-2 ml-0.5 text-lg font-bold text-text-3">T</span>
<Ziffer z={lz ? std[0] : "–"} />
<Ziffer z={lz ? std[1] : "–"} />
<span className="schild ml-0.5 text-lg font-bold text-text-3">H</span>
</div>
<figcaption className="schild text-[15px] text-text-3">Ohne Neustart</figcaption>
</figure>
)
}
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react"
import { bogenPfad, hauptleuchte } from "@/lib/anzeige"
import { Warnpanel } from "./Warnpanel"
import type { Start } from "@/lib/typen"
const ruhig: Start["zustand"] = { stufe: "ok", anzahl: 0, waechter_wach: true, stand: 1, update_laeuft: false }
describe("bogenPfad", () => {
it("beginnt links unten und endet bei vollem Ausschlag rechts unten", () => {
expect(bogenPfad(0, 1, 75)).toBe("M 35.05 132.5 A 75 75 0 1 1 164.95 132.5")
})
it("zeichnet 45 % Speicher als kleinen Bogen bis kurz vor die Spitze", () => {
// 45 % von 240° = 108° → Endwinkel 102°, also knapp links der Senkrechten.
expect(bogenPfad(0, 0.45, 75)).toBe("M 35.05 132.5 A 75 75 0 0 1 84.41 21.64")
})
it("klemmt Werte außerhalb von 0…1", () => {
expect(bogenPfad(-1, 2, 75)).toBe(bogenPfad(0, 1, 75))
})
})
describe("hauptleuchte", () => {
it("bleibt ruhig, wenn nichts offen ist", () => {
expect(hauptleuchte(ruhig)).toMatchObject({ art: "ok", mitte: "IN ORDNUNG" })
})
it("zeigt Bernstein bei gelben und Rot bei roten Hinweisen", () => {
expect(hauptleuchte({ ...ruhig, stufe: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 HINWEISE" })
expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 HINWEIS" })
})
it("meldet einen schweigenden Wächter vor allem anderen", () => {
expect(hauptleuchte({ ...ruhig, waechter_wach: false, anzahl: 3, stufe: "rot" }).art).toBe("stumm")
})
})
describe("Warnpanel", () => {
it("zeigt jede Lampe mit Name und Wert", () => {
render(
<Warnpanel
zustand={{ ...ruhig, stufe: "gelb", anzahl: 2 }}
lampen={[
{ id: "motor", label: "Motor", zustand: "ok", wert: "b11057" },
{ id: "jobs", label: "Jobs", zustand: "warn", wert: "2 Hinweise" },
]}
onAnsehen={() => {}}
/>,
)
expect(screen.getByText("Motor")).toBeInTheDocument()
expect(screen.getByText("b11057")).toBeInTheDocument()
expect(screen.getByText("braucht Aufmerksamkeit")).toBeInTheDocument()
expect(screen.getByRole("button", { name: /Achtung 2 HINWEISE/ })).toBeEnabled()
})
})
@@ -1,134 +0,0 @@
import { Gauge } from "lucide-react"
import { useVoiceTrace } from "@/lib/queries"
import type { VoiceTurn } from "@/lib/api"
// Balken-Stufen in kanonischer Reihenfolge (zeitlich disjunkt). Der Gedächtnis-Abruf läuft
// Hermes-intern und ist für MC2 nicht messbar — er steckt in „Hirn".
const SEGMENTS = [
{ key: "stt_ms", label: "STT", color: "#f59e0b" },
{ key: "vision_ms", label: "Sehen", color: "#a78bfa" },
{ key: "hirn_ms", label: "Hirn", color: "#2dd4bf" },
{ key: "gen_ms", label: "Antwort", color: "#60a5fa" },
] as const
const fmt = (ms: number | null | undefined): string =>
ms == null ? "–" : `${(ms / 1000).toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`
function ago(ts: number): string {
const s = Math.max(0, Date.now() / 1000 - ts)
if (s < 60) return `vor ${Math.round(s)} s`
if (s < 3600) return `vor ${Math.round(s / 60)} min`
return `vor ${Math.round(s / 3600)} h`
}
/** Summe der sichtbaren Balken-Stufen (STT gehört zur gefühlten Latenz dazu; total_ms des Backends
* misst nur den Chat-Request ohne STT). So stimmen Balkenbreite und angezeigte Zahl immer überein. */
function rowTotal(t: VoiceTurn): number {
return SEGMENTS.reduce((sum, s) => sum + ((t[s.key] as number | null) ?? 0), 0)
}
/** Dominante Stufe eines Turns = der „Täter". */
function culprit(t: VoiceTurn): { label: string; color: string; ms: number } | null {
let best: { label: string; color: string; ms: number } | null = null
for (const s of SEGMENTS) {
const ms = t[s.key] as number | null
if (ms != null && (best == null || ms > best.ms)) best = { label: s.label, color: s.color, ms }
}
return best
}
function TurnRow({ t, scale }: { t: VoiceTurn; scale: number }) {
const title = SEGMENTS.map((s) => `${s.label} ${fmt(t[s.key] as number | null)}`).join(" · ")
return (
<div className="flex items-center gap-2.5">
<span className="w-14 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">{ago(t.ts)}</span>
<div className="relative h-4 flex-1 overflow-hidden rounded-sm bg-muted/25" title={title}>
<div className="absolute inset-0 flex">
{SEGMENTS.map((s) => {
const ms = t[s.key] as number | null
if (!ms || ms <= 0) return null
return (
<div
key={s.key}
style={{ width: `${(ms / scale) * 100}%`, background: s.color }}
className="h-full first:rounded-l-sm"
/>
)
})}
</div>
{t.error && (
<div className="absolute inset-0 flex items-center justify-center bg-rose-500/15 text-[10px] font-semibold text-rose-300">
Fehler
</div>
)}
</div>
<span className="w-12 shrink-0 text-right font-mono text-[11px] font-semibold tabular-nums text-foreground">
{t.error ? "–" : fmt(rowTotal(t))}
</span>
</div>
)
}
export function LatencyCard() {
const { data: turns } = useVoiceTrace(12)
const list = turns ?? []
const scale = Math.max(1, ...list.map((t) => rowTotal(t)))
const latest = list[0]
const lead = latest ? culprit(latest) : null
return (
<div className="mc-card p-5">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Gauge className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Latenz je Turn</h2>
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span>
</div>
{latest ? (
<div className="mt-2 flex items-baseline gap-2">
<span className={`font-space text-3xl font-bold tracking-tight tabular-nums ${latest.error ? "text-rose-400" : "text-foreground"}`}>
{latest.error ? "Fehler" : fmt(rowTotal(latest))}
</span>
<span className="text-xs text-muted-foreground">
letzter Turn{!latest.error && lead && <> · Täter: <span style={{ color: lead.color }} className="font-semibold">{lead.label}</span> {fmt(lead.ms)}</>}
</span>
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground">Noch kein Voice-Turn aufgezeichnet.</div>
)}
{latest && !latest.error && (
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70">
{SEGMENTS.filter((s) => (latest[s.key] as number | null) != null).map((s) => `${s.label} ${fmt(latest[s.key] as number)}`).join(" · ")}
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1 pt-1">
{SEGMENTS.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
</div>
))}
</div>
</div>
{list.length > 0 ? (
<div className="space-y-1.5">
{list.map((t) => <TurnRow key={t.id} t={t} scale={scale} />)}
</div>
) : (
<div className="flex h-[120px] items-center justify-center px-6 text-center text-xs text-muted-foreground">
Sprich einmal mit Lucy — dann erscheint hier der Zeit-Wasserfall pro Turn, damit man den einen Hänger sofort sieht.
</div>
)}
<div className="mt-3 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-muted-foreground/70">
„Hirn" = Zeit bis zum ersten Wort (Agent + Gedächtnis-Suche + Modell); Tool-Runden stecken in „Antwort". Reine
Telegram-Turns laufen an MC2 vorbei und erscheinen hier nicht.
</div>
</div>
)
}
@@ -1,41 +0,0 @@
import { describe, expect, it } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import { LiveAreaChart } from "./LiveAreaChart"
import type { ChartSeries } from "./chartTypes"
const SERIEN: ChartSeries[] = [{ key: "cpu", label: "CPU", color: "#2dd4bf" }]
const DATEN = [
{ t: 1_700_000_000_000, cpu: 12 },
{ t: 1_700_000_003_000, cpu: 34 },
]
// Die Lazy-Huelle ist der Grund, warum Recharts nicht mehr im Startbuendel liegt (P0).
// Wichtig ist dabei nur eines: Der Platzhalter muss GENAU so hoch sein wie das spaetere
// Diagramm — sonst springt die Karte beim Nachladen.
describe("LiveAreaChart (Lazy-Huelle)", () => {
it("haelt vor dem Nachladen die volle Diagrammhoehe frei", () => {
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
const platzhalter = container.querySelector<HTMLElement>('[aria-hidden="true"]')
expect(platzhalter).not.toBeNull()
expect(platzhalter!.style.height).toBe("176px")
})
it("uebernimmt eine abweichende Hoehe", () => {
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={96} />)
expect(container.querySelector<HTMLElement>('[aria-hidden="true"]')!.style.height).toBe("96px")
})
it("laedt die Recharts-Umsetzung nach und zeigt sie an", async () => {
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
await waitFor(() => {
expect(container.querySelector(".recharts-responsive-container")).not.toBeNull()
})
// Platzhalter ist verschwunden, sobald das Diagramm da ist.
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
})
it("ist fuer Screenreader stumm — der Platzhalter ist reine Optik", () => {
render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
expect(screen.queryByText(/l(ä|ae)dt/i)).toBeNull()
})
})
@@ -1,33 +0,0 @@
import { Suspense, lazy } from "react"
import type { LiveAreaChartProps } from "./chartTypes"
export type { ChartSeries } from "./chartTypes"
// Lazy-Hülle um die Recharts-Umsetzung (v3-Umbau P0).
//
// WARUM: Das Cockpit ist die Startseite und wird deshalb NICHT lazy geladen. Über
// SystemStatusCard / TokenPerformanceCard zog es Recharts samt d3-Abhängigkeiten in das
// Start-Bündel — gemessen ~95 kB gzip von 220 kB, für vier Flächendiagramme, die man erst
// sieht, wenn die Seite längst steht. Jetzt kommt Recharts als eigener Chunk nach.
//
// Der Platzhalter hat exakt die Höhe des Diagramms (Prop `height`), damit beim Nachladen
// nichts springt — die Karte ist von der ersten Zeichnung an so hoch wie am Ende.
const Impl = lazy(() => import("./LiveAreaChartImpl"))
export function LiveAreaChart(props: LiveAreaChartProps) {
return (
<Suspense fallback={<ChartPlatzhalter height={props.height ?? 176} />}>
<Impl {...props} />
</Suspense>
)
}
// Ruhiges Skelett statt Spinner: im LAN ist der Chunk in Millisekunden da, ein blinkender
// Lade-Kringel wäre nur Unruhe. Die Linie deutet die Grundachse des Diagramms an.
function ChartPlatzhalter({ height }: { height: number }) {
return (
<div style={{ height }} className="flex w-full items-end" aria-hidden="true">
<div className="h-px w-full bg-border/40" />
</div>
)
}
@@ -1,105 +0,0 @@
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import type { LiveAreaChartProps } from "./chartTypes"
// Recharts-Umsetzung des Verlaufsgraphen. Diese Datei ist der EINZIGE Ort im Projekt, der
// Recharts importiert — sie wird ausschließlich per lazy() aus LiveAreaChart.tsx geladen und
// landet dadurch in einem eigenen Chunk statt im Start-Bündel (v3-Umbau P0).
const GRID = "rgba(130,130,150,0.14)"
const AXIS = "rgba(130,130,150,0.85)"
function niceCeil(v: number): number {
if (v <= 0) return 10
const mag = Math.pow(10, Math.floor(Math.log10(v)))
const n = v / mag
const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10
return step * mag
}
function fmt(v: number, unit: string): string {
const n = unit === "%" ? Math.round(v) : v >= 1000 ? `${(v / 1000).toFixed(1)}k` : Math.round(v).toString()
return `${n}${unit}`
}
function ChartTooltip({ active, payload, unit, label }: any) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-border/70 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur">
{Number.isFinite(label) && (
<div className="mb-1 border-b border-border/40 pb-1 font-mono text-[10px] text-muted-foreground/80">
{new Date(label).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</div>
)}
<div className="space-y-1">
{payload.map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2 text-[11px] font-mono">
<span className="h-2 w-2 rounded-full" style={{ background: p.color }} />
<span className="uppercase tracking-wider text-muted-foreground">{p.name}</span>
<span className="ml-auto pl-3 font-bold tabular-nums text-foreground">{fmt(p.value, unit)}</span>
</div>
))}
</div>
</div>
)
}
/** Wiederverwendbarer Live-Verlaufsgraph (Recharts Area, glatte Splines, Gradient-Fill,
* Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice).
* showTime=true blendet die Zeitachse ein (t in ms; Label-Format folgt der Spannweite). */
export default function LiveAreaChartImpl({
data, series, unit = "%", yMode = "percent", height = 176, showTime = false,
}: LiveAreaChartProps) {
const peak = data.reduce(
(m, row) => series.reduce((mm, s) => Math.max(mm, Number(row[s.key]) || 0), m), 0
)
const yMax = yMode === "percent"
? Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25))
: Math.max(niceCeil(peak * 1.15), 10)
// Kurze Fenster (Live ≈ 2 min) brauchen Sekunden, lange (1 h / 24 h) nur HH:MM.
const spanMs = data.length > 1 ? Number(data[data.length - 1]?.t) - Number(data[0]?.t) : 0
const fmtTime = (v: number) =>
new Date(v).toLocaleTimeString("de-DE",
spanMs <= 10 * 60_000 ? { hour: "2-digit", minute: "2-digit", second: "2-digit" }
: { hour: "2-digit", minute: "2-digit" })
return (
<div style={{ height }} className="w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 6, bottom: 0, left: -12 }}>
<defs>
{series.map((s) => (
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color} stopOpacity={0.22} />
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} stroke={GRID} />
{showTime ? (
<XAxis
dataKey="t" type="number" domain={["dataMin", "dataMax"]}
tickFormatter={fmtTime} tickCount={5} minTickGap={40} interval="preserveStartEnd"
axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }} height={18}
/>
) : (
<XAxis dataKey="t" hide />
)}
<YAxis
domain={[0, yMax]} ticks={[0, yMax / 2, yMax]} tickFormatter={(v) => fmt(v, unit)}
width={42} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }}
/>
<Tooltip content={<ChartTooltip unit={unit} />} cursor={{ stroke: AXIS, strokeOpacity: 0.4, strokeDasharray: "3 3" }} />
{series.map((s) => (
<Area
key={s.key} type="monotone" dataKey={s.key} name={s.label}
stroke={s.color} strokeWidth={2} fill={`url(#grad-${s.key})`}
dot={false} activeDot={{ r: 3, strokeWidth: 0 }}
isAnimationActive={false} connectNulls
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
)
}
@@ -1,27 +0,0 @@
import { cn } from "@/lib/utils"
// Bereichs-Schalter der Leistungs-Karten: Live (2-min-Browser-Store) vs. 1h/24h
// (Backend-Ringpuffer, /api/system/history). Bewusst winzig — kein Chart-Gebirge.
export type ChartRange = "live" | "1h" | "24h"
export const RANGE_MINUTES: Record<Exclude<ChartRange, "live">, number> = { "1h": 60, "24h": 1440 }
const LABELS: Record<ChartRange, string> = { live: "Live", "1h": "1 h", "24h": "24 h" }
export function RangeSwitch({ value, onChange }: { value: ChartRange; onChange: (r: ChartRange) => void }) {
return (
<div className="flex rounded-lg border border-border/40 bg-background/30 p-0.5">
{(Object.keys(LABELS) as ChartRange[]).map((r) => (
<button
key={r}
onClick={() => onChange(r)}
className={cn(
"rounded-md px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide transition-all cursor-pointer",
value === r ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{LABELS[r]}
</button>
))}
</div>
)
}
@@ -1,89 +0,0 @@
import { useMemo, useState } from "react"
import { Cpu } from "lucide-react"
import { useSystemHistory } from "@/lib/useSystemHistory"
import { useMetricHistory } from "@/lib/queries"
import { gb } from "@/lib/format"
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
const SERIES: ChartSeries[] = [
{ key: "cpu", label: "CPU", color: "#2dd4bf" },
{ key: "ram", label: "RAM", color: "#38bdf8" },
{ key: "gpu", label: "GPU", color: "#a78bfa" },
{ key: "disk", label: "Disk", color: "#fbbf24" },
]
export function SystemStatusCard() {
const { sys, hist } = useSystemHistory()
const [range, setRange] = useState<ChartRange>("live")
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
// Live = 3-s-Browser-Store (~2 min); 1h/24h = Backend-Ringpuffer mit echten Zeitstempeln.
const chartData = useMemo(() => {
if (range === "live") return hist
return (histQ.data?.points ?? []).map((p) => ({ t: p.t * 1000, cpu: p.cpu, ram: p.ram, gpu: p.gpu, disk: p.disk }))
}, [range, hist, histQ.data])
const hasGpu = !!(sys?.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null)
const activeSeries = SERIES.filter((s) => s.key !== "gpu" || hasGpu)
const current: Record<string, number | null | undefined> = {
cpu: sys?.cpu?.percent, ram: sys?.ram?.percent,
gpu: hasGpu ? sys!.gpu!.busy_percent : null, disk: sys?.disk?.percent,
}
const detail: Record<string, string> = {
cpu: sys?.cpu?.cores ? `${sys.cpu.cores} Cores` : "",
ram: sys ? `${gb(sys.ram.used)}/${gb(sys.ram.total)} GB` : "",
gpu: hasGpu ? `${gb(sys!.gpu!.gtt_used!)}/${gb(sys!.gpu!.gtt_total!)} GB` : "",
disk: sys?.disk ? `${gb(sys.disk.used)}/${gb(sys.disk.total)} GB` : "",
}
return (
<div className="flex flex-col justify-between mc-card p-5">
<div>
<div className="mb-3 flex items-center gap-2">
<Cpu className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">System-Status</h2>
{range === "live" && (
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span>
)}
<div className="ml-auto"><RangeSwitch value={range} onChange={setRange} /></div>
</div>
{sys ? (
<>
<div className="mb-2 grid grid-cols-2 gap-x-4 gap-y-1.5">
{activeSeries.map((s) => (
<div key={s.key} className="flex min-w-0 items-center gap-1.5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: s.color }} />
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
<span className="shrink-0 font-mono text-xs font-bold tabular-nums text-foreground">{Math.round(current[s.key] ?? 0)}%</span>
{detail[s.key] && <span className="truncate font-mono text-[10px] text-muted-foreground/60">{detail[s.key]}</span>}
</div>
))}
</div>
{range !== "live" && chartData.length === 0 && (
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
</div>
)}
{(range === "live" || chartData.length > 0) && (
<LiveAreaChart data={chartData} series={activeSeries} unit="%" yMode="percent" height={176} showTime />
)}
</>
) : (
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">Lade Systemdaten…</div>
)}
</div>
{sys?.temp && (sys.temp.cpu || sys.temp.gpu) && (
<div className="mt-3 flex gap-4 border-t border-border/30 pt-3 font-mono text-xs text-muted-foreground/80">
{sys.temp.cpu != null && <span>CPU Temp: {sys.temp.cpu} °C</span>}
{sys.temp.gpu != null && <span>GPU Temp: {sys.temp.gpu} °C</span>}
</div>
)}
</div>
)
}
@@ -1,103 +0,0 @@
import { useMemo, useState } from "react"
import { Activity } from "lucide-react"
import { useTokenStats, useMetricHistory } from "@/lib/queries"
import { useTokHistory } from "@/lib/metricsStore"
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
const SERIES: ChartSeries[] = [
{ key: "prompt", label: "Prompt", color: "#f59e0b" },
{ key: "completion", label: "Antwort", color: "#2dd4bf" },
]
export function TokenPerformanceCard() {
const { data: ts } = useTokenStats()
const hist = useTokHistory() // Live-Durchsatz aus dem modul-globalen Store (~2 min)
const [range, setRange] = useState<ChartRange>("live")
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
// 1h/24h: Raten aus den Deltas der Gesamtzähler (tok/s zwischen zwei Sample-Punkten).
const chartData = useMemo(() => {
if (range === "live") return hist
const pts = histQ.data?.points ?? []
const out: { t: number; prompt: number; completion: number }[] = []
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i]
if (a.tp == null || b.tp == null || a.tc == null || b.tc == null) continue
const dt = Math.max(b.t - a.t, 1)
out.push({
t: b.t * 1000,
prompt: Math.max(0, (b.tp - a.tp) / dt),
completion: Math.max(0, (b.tc - a.tc) / dt),
})
}
return out
}, [range, hist, histQ.data])
const last = hist[hist.length - 1]
const curRate = last ? Math.round(last.prompt + last.completion) : 0
return (
<div className="mc-card p-5">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Activity className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Token-Durchsatz</h2>
{range === "live" && (
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span>
)}
</div>
{ts && (
<div className="mt-2 flex items-baseline gap-2">
<span className="font-space text-3xl font-bold tracking-tight text-foreground tabular-nums">
{curRate.toLocaleString("de-DE")}
</span>
<span className="text-xs text-muted-foreground">tok/s aktuell</span>
</div>
)}
{ts && (
<div className="mt-0.5 space-y-0.5 font-mono text-[11px] text-muted-foreground/70">
<div>
{ts.total_tokens.toLocaleString("de-DE")} Tokens gesamt · <span className="text-emerald-400/90">{ts.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })} € gespart</span>
</div>
<div className="text-muted-foreground/55">
Input {ts.prompt_tokens.toLocaleString("de-DE")} · Output {ts.completion_tokens.toLocaleString("de-DE")}
</div>
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2 pt-1">
<RangeSwitch value={range} onChange={setRange} />
<div className="flex flex-col items-end gap-1.5">
{SERIES.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
<span className="font-mono text-xs font-bold tabular-nums text-foreground">
{Math.round((last?.[s.key as "prompt" | "completion"]) ?? 0)}
</span>
</div>
))}
</div>
</div>
</div>
{range !== "live" && chartData.length === 0 ? (
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
</div>
) : ts ? (
<LiveAreaChart data={chartData} series={SERIES} unit=" tok/s" yMode="auto" height={150} showTime />
) : (
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">Lade Durchsatz…</div>
)}
<div className="mt-2 border-t border-border/30 pt-2 text-[10px] text-muted-foreground/70">
Durchsatz aus dem Gateway abgeleitet (Prefill vs. Generierung). Flach bei Leerlauf.
</div>
</div>
)
}
@@ -1,15 +0,0 @@
// Typen der Verlaufs-Diagramme. Bewusst eine eigene, winzige Datei: sie wird sowohl vom
// Lazy-Wrapper (LiveAreaChart.tsx) als auch von der Recharts-Umsetzung (LiveAreaChartImpl.tsx)
// gebraucht. Läge sie in einer der beiden, würde ein Typ-Import die Datei aneinander binden —
// und genau das soll die Aufteilung ja verhindern.
export type ChartSeries = { key: string; label: string; color: string }
export interface LiveAreaChartProps {
data: any[]
series: ChartSeries[]
unit?: string
yMode?: "percent" | "auto"
height?: number
showTime?: boolean
}
@@ -1,68 +0,0 @@
import { api } from "@/lib/api"
import { useJobs, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { fmtBytes, fmtEta } from "@/lib/format"
export function JobsBar() {
const qc = useQueryClient()
const { data: jobs = [] } = useJobs()
const { showAlert, dialogElement } = useDialog()
async function cancelJob(jobId: string) {
try {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
qc.invalidateQueries({ queryKey: qk.jobs })
} catch (e: any) {
showAlert("Fehler", e.message)
}
}
const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
if (active.length === 0 && recent.length === 0) return null
return (
<div className="space-y-3 mc-card p-4">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">Aktive Downloads</div>
{active.map((j) => (
<div key={j.id} className="space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold truncate max-w-[250px]">{j.label}</span>
<div className="flex items-center gap-3">
<span className="text-muted-foreground font-mono">
{j.progress ?? 0}% • {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
{j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""}
</span>
<button
onClick={() => cancelJob(j.id)}
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer"
>
Abbrechen
</button>
</div>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${j.progress ?? 0}%` }} />
</div>
</div>
))}
{recent.map((j) => (
<div key={j.id} className="flex justify-between items-center text-xs text-muted-foreground px-1">
<span className="truncate">{j.label}</span>
<span className={cn(
"font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",
j.state === "done" ? "bg-emerald-500/10 text-emerald-400" : "bg-amber-500/10 text-amber-400"
)}>
{j.state}
</span>
</div>
))}
{dialogElement}
</div>
)
}
@@ -1,181 +0,0 @@
import { useEffect, useState } from "react"
import { Sliders, RotateCcw, Check, Loader2, MessageSquare, Code2 } from "lucide-react"
import { updateRoutingPolicy, type RoutingPolicy } from "@/lib/api"
import { useRoutingPolicy, useQueryClient, qk } from "@/lib/queries"
import { cn } from "@/lib/utils"
// Welche Policy-Felder zu welcher Lane gehören (Rest ist global).
const CHAT_FIELDS: (keyof RoutingPolicy)[] = ["fast", "heavy", "heavy_chars"]
const CODING_FIELDS: (keyof RoutingPolicy)[] = ["coder_lite", "coder", "coding_escalate_chars"]
export function LaneEditor() {
const qc = useQueryClient()
const { data: meta, isLoading } = useRoutingPolicy()
const [draft, setDraft] = useState<RoutingPolicy | null>(null)
const [saving, setSaving] = useState(false)
const [err, setErr] = useState("")
const [savedAt, setSavedAt] = useState(0)
// Draft initialisieren, sobald die Policy geladen ist (und nicht überschreiben, wenn schon editiert).
useEffect(() => {
if (meta?.policy && !draft) setDraft({ ...meta.policy })
}, [meta, draft])
if (isLoading || !meta || !draft) {
return (
<div className="mc-card p-5 text-xs text-muted-foreground">
Lade Routing-Policy…
</div>
)
}
const fieldSpec = (key: keyof RoutingPolicy) => meta.fields.find((f) => f.key === key)!
const dirty = (Object.keys(draft) as (keyof RoutingPolicy)[]).some((k) => draft[k] !== meta.policy[k])
const set = <K extends keyof RoutingPolicy>(key: K, value: RoutingPolicy[K]) => {
setDraft((d) => (d ? { ...d, [key]: value } : d))
setErr("")
}
const resetField = (key: keyof RoutingPolicy) => set(key, meta.defaults[key])
async function save() {
if (!draft) return
const patch: Partial<RoutingPolicy> = {}
for (const k of Object.keys(draft) as (keyof RoutingPolicy)[]) {
if (draft[k] !== meta!.policy[k]) (patch as any)[k] = draft[k]
}
if (Object.keys(patch).length === 0) return
setSaving(true)
setErr("")
try {
const { policy } = await updateRoutingPolicy(patch)
setDraft({ ...policy })
qc.invalidateQueries({ queryKey: qk.routingPolicy })
qc.invalidateQueries({ queryKey: qk.routing })
setSavedAt(Date.now())
setTimeout(() => setSavedAt(0), 2000)
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setSaving(false)
}
}
function Field({ k }: { k: keyof RoutingPolicy }) {
const spec = fieldSpec(k)
const val = draft![k]
const isDefault = draft![k] === meta!.defaults[k]
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-[10px] font-semibold text-muted-foreground">{spec.label}</label>
{!isDefault && (
<button
onClick={() => resetField(k)}
className="flex items-center gap-0.5 text-[9px] text-muted-foreground/60 hover:text-foreground transition-colors cursor-pointer"
title={`Auf Default zurücksetzen (${String(meta!.defaults[k]) || "leer"})`}
>
<RotateCcw className="h-2.5 w-2.5" /> Default
</button>
)}
</div>
{spec.type === "bool" ? (
<button
onClick={() => set(k, !val as any)}
className={cn(
"flex h-8 w-full items-center justify-between rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
val ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300" : "border-border/40 bg-background/40 text-muted-foreground",
)}
>
<span>{val ? "An" : "Aus"}</span>
<span className={cn("h-3.5 w-3.5 rounded-full transition-colors", val ? "bg-emerald-400" : "bg-muted-foreground/40")} />
</button>
) : spec.type === "int" ? (
<input
type="number"
value={val as number}
min={spec.min}
max={spec.max}
aria-label={spec.label}
onChange={(e) => set(k, Number(e.target.value) as any)}
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 px-3 font-mono text-[11px] text-foreground outline-none focus:border-primary/50"
/>
) : (
<input
type="text"
value={val as string}
aria-label={spec.label}
spellCheck={false}
placeholder={k === "coder_lite" ? "(leer = aus)" : ""}
onChange={(e) => set(k, e.target.value as any)}
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 px-3 font-mono text-[11px] text-foreground outline-none focus:border-primary/50"
/>
)}
</div>
)
}
return (
<div className="mc-card p-5 space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Sliders className="h-4.5 w-4.5 text-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Lane-Routing &amp; Policy</span>
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 border border-emerald-500/20">hot-reload</span>
</div>
<div className="flex items-center gap-2">
{err && <span className="text-[10px] text-red-400 max-w-[280px] truncate" title={err}>{err}</span>}
{savedAt > 0 && (
<span className="flex items-center gap-1 text-[10px] text-emerald-400"><Check className="h-3.5 w-3.5" /> gespeichert</span>
)}
<button
onClick={save}
disabled={!dirty || saving}
className={cn(
"h-8 px-4 rounded-lg text-[10px] font-bold uppercase tracking-wide transition-all flex items-center gap-1.5",
dirty && !saving
? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer shadow-md shadow-primary/10"
: "bg-background/40 text-muted-foreground/50 border border-border/40 cursor-not-allowed",
)}
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Speichern
</button>
</div>
</div>
<p className="text-[10px] text-muted-foreground/70 leading-relaxed -mt-1">
Welches echte Modell hinter den virtuellen Lanes <code className="text-cyan-300">chat</code> und{" "}
<code className="text-cyan-300">coding</code> steckt. Änderungen greifen sofort (kein Neustart). Die
Keyword-Heuristiken bleiben im Code.
</p>
<div className="grid gap-4 md:grid-cols-2">
{/* chat-Lane */}
<div className="rounded-xl border border-border/40 bg-background/25 p-4 space-y-3">
<div className="flex items-center gap-2 border-b border-border/30 pb-2">
<MessageSquare className="h-4 w-4 text-teal-400" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground">chat</span>
<span className="text-[9px] text-muted-foreground/60 font-mono">(= auto)</span>
</div>
{CHAT_FIELDS.map((k) => <Field key={k} k={k} />)}
</div>
{/* coding-Lane */}
<div className="rounded-xl border border-border/40 bg-background/25 p-4 space-y-3">
<div className="flex items-center gap-2 border-b border-border/30 pb-2">
<Code2 className="h-4 w-4 text-indigo-400" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground">coding</span>
<span className="text-[9px] text-muted-foreground/60 font-mono">(agentisch → immer Coder)</span>
</div>
{CODING_FIELDS.map((k) => <Field key={k} k={k} />)}
</div>
</div>
{/* Globale Schalter */}
<div className="rounded-xl border border-border/40 bg-background/25 p-4">
<div className="max-w-xs"><Field k="fast_no_think" /></div>
</div>
</div>
)
}
@@ -1,60 +0,0 @@
import { type Fit } from "@/lib/api"
import { cn } from "@/lib/utils"
import { ROLES, roleTone, roleMeta } from "@/lib/roleMeta"
// Klartext-Schicht: Rollen-Namen/Icons/Farben leben jetzt zentral in lib/roleMeta.ts.
// Re-Export hält bestehende Importe (Cockpit, Workbench, …) stabil.
export { ROLES, roleTone, roleMeta }
// Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip.
// `dense` = nur Kurzform (für enge Badges).
export function RoleLabel({
role,
dense = false,
className,
}: {
role?: string | null
dense?: boolean
className?: string
}) {
const meta = roleMeta(role)
const Icon = meta.icon
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border tracking-wide",
meta.tone,
className,
)}
title={`${meta.desc} (${meta.role})`}
>
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
{dense ? meta.short : meta.label}
</span>
)
}
export function FitBadge({ fit }: { fit: Fit }) {
const tone = {
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
marginal: "bg-amber-500/15 text-amber-400 border border-amber-500/20",
too_tight: "bg-red-500/15 text-red-400 border border-red-500/20",
}[fit.level]
return (
<span className={cn("rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider font-mono", tone)}>
{fit.text} • {fit.req_gb} GB RAM
</span>
)
}
export function getBrandInfo(name: string) {
const low = name.toLowerCase()
if (low.includes("qwen")) return { name: "Qwen", color: "bg-purple-500/20 text-purple-300 border-purple-500/30", initial: "Q" }
if (low.includes("gemma")) return { name: "Gemma", color: "bg-blue-500/20 text-blue-300 border-blue-500/30", initial: "G" }
if (low.includes("llama")) return { name: "Llama", color: "bg-red-500/20 text-red-300 border-red-500/30", initial: "🦙" }
if (low.includes("mistral") || low.includes("mixtral")) return { name: "Mistral", color: "bg-orange-500/20 text-orange-300 border-orange-500/30", initial: "M" }
if (low.includes("deepseek")) return { name: "DeepSeek", color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", initial: "D" }
if (low.includes("hermes") || low.includes("nous")) return { name: "Hermes", color: "bg-amber-500/20 text-amber-300 border-amber-500/30", initial: "H" }
if (low.includes("phi")) return { name: "Phi", color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", initial: "Φ" }
return { name: "Other", color: "bg-slate-500/20 text-slate-300 border-slate-500/30", initial: "AI" }
}
@@ -1,171 +0,0 @@
import { useState } from "react"
import { X, Check, Zap, AlertTriangle } from "lucide-react"
import { api, type ModelInfo, type DraftInfo } from "@/lib/api"
import { useDrafts } from "@/lib/queries"
import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils"
/**
* Idiotensichere Speculative-Decoding-Konfiguration für EIN Modell.
* Zeigt nur VOCAB-KOMPATIBLE Drafts als wählbar; inkompatible werden gesperrt
* und mit Begründung angezeigt. So kann nie ein kaputter Draft gesetzt werden
* (der das Modell beim Laden scheitern ließe).
*/
export function SpecDraftModal({
model, onClose, onChanged,
}: { model: ModelInfo; onClose: () => void; onChanged: () => void }) {
const { data, isLoading } = useDrafts(model.gguf_path)
const [busy, setBusy] = useState<string | null>(null)
const [err, setErr] = useState("")
const tv = data?.target_vocab
const drafts = data?.drafts ?? []
const compatibles = drafts.filter((d) => d.compatible === true)
const currentFile = model.spec_draft_model
async function apply(draftPath: string | null) {
setBusy(draftPath ?? "__clear__")
setErr("")
try {
await api(`/api/models/${encodeURIComponent(model.name)}/draft`, {
method: "POST",
body: JSON.stringify({ draft_path: draftPath }),
})
onChanged()
onClose()
} catch (e: any) {
setErr(String(e?.message || e))
setBusy(null)
}
}
const vocabLabel = (v?: { pre: string | null; n_vocab: number | null } | null) =>
v ? `${v.pre ?? "?"} · ${v.n_vocab?.toLocaleString() ?? "?"} Tokens` : "—"
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-lg 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-2">
<Zap className="h-4 w-4" /> Speculative Draft
</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="text-xs text-muted-foreground leading-relaxed">
Beschleunigt die Token-Generierung mit einem kleinen "Draft"-Modell. Voraussetzung:
der Draft muss den <strong className="text-foreground">exakt gleichen Tokenizer (Vocab)</strong> haben
wie das Modell — sonst lehnt llama.cpp es ab.
</div>
{/* Ziel-Vocab */}
<div className="rounded-lg border border-border/40 bg-background/30 px-3 py-2 text-[11px] font-mono flex items-center justify-between">
<span className="text-muted-foreground">{model.name.split("/").pop()?.replace(/\.gguf$/i, "")}</span>
<span className="text-foreground">Vocab: {vocabLabel(tv)}</span>
</div>
{/* Aktueller Zustand */}
{model.spec_active && currentFile && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 flex items-center justify-between gap-2">
<span className="text-[11px] text-emerald-400 font-mono flex items-center gap-1.5 truncate">
<Check className="h-3.5 w-3.5 shrink-0" /> Aktiv: {currentFile}
</span>
<button
onClick={() => apply(null)}
disabled={busy !== null}
className="h-6 px-2.5 rounded border border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10 text-amber-400 text-[9px] font-bold uppercase shrink-0 cursor-pointer disabled:opacity-50"
>
Deaktivieren
</button>
</div>
)}
{!data?.target_exists && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-[11px] text-amber-400 flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Modell-GGUF noch nicht vorhanden — Spec-Draft ist nach dem Download konfigurierbar.
</div>
)}
{/* Draft-Liste */}
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
{isLoading ? (
<div className="text-xs text-muted-foreground py-6 text-center">Prüfe Vocab-Kompatibilität…</div>
) : drafts.length === 0 ? (
<div className="text-[11px] text-muted-foreground border border-dashed border-border/50 rounded-lg p-4 text-center">
Keine Draft-Modelle in <code className="font-mono">/srv/models/drafts</code>.
Lade ein kleines same-family-Modell (z.B. Qwen3-0.6B) und lege es dort ab.
</div>
) : (
drafts.map((d: DraftInfo) => {
const isCurrent = d.filename === currentFile
const ok = d.compatible === true
return (
<div
key={d.path}
className={cn(
"rounded-lg border px-3 py-2 flex items-center justify-between gap-2 text-left",
ok ? "border-border/40 bg-background/20" : "border-border/20 bg-background/10 opacity-60",
isCurrent && "border-primary/40 bg-primary/10",
)}
>
<div className="min-w-0">
<div className="text-[11px] font-mono font-semibold text-foreground truncate flex items-center gap-1.5">
{d.filename}
{d.mtp && (
<span
className="shrink-0 rounded bg-indigo-500/15 px-1.5 py-0.5 text-[8px] font-bold uppercase tracking-wider text-indigo-400 border border-indigo-500/25"
title="MTP-Kopf (Multi-Token-Prediction) — by-construction vocab-identisch, höchste Akzeptanzrate"
>
MTP
</span>
)}
</div>
<div className="text-[9px] text-muted-foreground font-mono">
{fmtSize(d.size_bytes)} · Vocab: {vocabLabel(d.vocab)}
</div>
</div>
{ok ? (
isCurrent ? (
<span className="text-[9px] font-bold uppercase text-primary flex items-center gap-1 shrink-0"><Check className="h-3.5 w-3.5" /> Aktiv</span>
) : (
<button
onClick={() => apply(d.path)}
disabled={busy !== null}
className="h-7 px-3 rounded-lg border border-emerald-500/30 bg-emerald-500/5 hover:bg-emerald-500/15 text-emerald-400 text-[9px] font-bold uppercase shrink-0 cursor-pointer disabled:opacity-50"
>
Aktivieren
</button>
)
) : (
<span
className="text-[9px] font-bold uppercase text-amber-400/80 flex items-center gap-1 shrink-0"
title={d.compatible === false
? `Inkompatibel: Tokenizer/Vocab weicht ab (Draft ${d.vocab?.pre}/${d.vocab?.n_vocab} ≠ Modell ${tv?.pre}/${tv?.n_vocab}).`
: "Kompatibilität nicht prüfbar (Datei fehlt)."}
>
<AlertTriangle className="h-3.5 w-3.5" /> {d.compatible === false ? "Vocab ≠" : "n/a"}
</span>
)}
</div>
)
})
)}
</div>
{/* Hinweis, wenn Drafts da sind aber keiner kompatibel */}
{!isLoading && data?.target_exists && drafts.length > 0 && compatibles.length === 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-[11px] text-amber-400 leading-relaxed">
Kein vocab-kompatibler Draft verfügbar — Speculative Decoding ist für dieses Modell nicht möglich.
Es braucht einen Draft mit identischem Tokenizer (pre=<span className="font-mono">{tv?.pre}</span>,
n_vocab=<span className="font-mono">{tv?.n_vocab?.toLocaleString()}</span>).
</div>
)}
{err && <div className="text-[10px] text-red-400 font-mono">{err}</div>}
</div>
</div>
)
}
@@ -1,203 +0,0 @@
import { useState } from "react"
import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react"
import { setGroup, type ModelInfo } from "@/lib/api"
import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { RoleLabel, roleMeta } from "@/components/models/ModelBadges"
import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils"
// „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder
// bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar,
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
export function WarmSetManager() {
const qc = useQueryClient()
const { data: modelsResp } = useModels()
const { data: groupsResp } = useGroups()
const { data: sysStatus } = useSystemStatus()
const { data: brain } = useHermesBrain()
const { showAlert, dialogElement } = useDialog()
const [picking, setPicking] = useState(false)
const [busy, setBusy] = useState(false)
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const group = groupsResp?.groups?.brains
const members = group?.members ?? []
const byName = (name: string) => models.find((m) => m.name === name)
const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name
const memberModels = members.map(byName).filter(Boolean) as ModelInfo[]
const B = 1024 ** 3
const bud = brain?.budget
const weightsGb = memberModels.reduce((a, m) => a + (m.size_bytes || 0), 0) / B
const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0)
// Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte.
const reservedGb = bud?.warm_projected_gb ?? weightsGb
const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0
const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete)
async function save(next: string[]) {
setBusy(true)
try {
await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true)
qc.invalidateQueries({ queryKey: qk.groups })
qc.invalidateQueries({ queryKey: qk.models })
qc.invalidateQueries({ queryKey: qk.hermesBrain })
} catch (e: any) {
showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`)
} finally {
setBusy(false)
}
}
function remove(name: string) {
const m = byName(name)
if (m && roleMeta(m.role).protected) {
showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`)
return
}
void save(members.filter((x) => x !== name))
}
function add(name: string) {
setPicking(false)
void save([...members, name])
}
if (!group) {
return (
<div className="mc-card p-5">
<Header />
<p className="mt-3 text-xs text-muted-foreground">
Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist.
</p>
{dialogElement}
</div>
)
}
return (
<div className="mc-card p-5 space-y-4">
<Header />
{/* Mitglieder */}
<div className="space-y-1.5">
{memberModels.length === 0 && (
<div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div>
)}
{memberModels.map((m) => {
const isWarm = running.includes(m.name)
const prot = roleMeta(m.role).protected
return (
<div key={m.name} className="flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<span className={cn("h-2 w-2 shrink-0 rounded-full ring-2 ring-black/40", isWarm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")}
title={isWarm ? "Gerade warm (bereit)" : "Reserviert — lädt bei Bedarf sofort"} />
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground" title={m.name}>{shortName(m.name)}</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</div>
{prot ? (
<span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span>
) : (
<button
onClick={() => remove(m.name)}
disabled={busy}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground transition-colors hover:bg-red-500/5 hover:text-red-400 cursor-pointer disabled:opacity-50"
title="Aus dem Immer-bereit-Set nehmen (lädt dann nur noch bei Bedarf)"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)
})}
</div>
{/* Hinzufügen */}
<button
onClick={() => setPicking(true)}
disabled={busy || eligible.length === 0}
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/50 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary cursor-pointer disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" /> Modell dauerhaft bereithalten
</button>
{/* Speicher-Budget */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Modellgewichte + KV-Cache, berechnet aus den echten Architektur-Daten jedes Modells (Layer × KV-Köpfe × Kontext) und seiner KV-Quantisierung. So viel legt llama.cpp beim Laden wirklich für den eingestellten Kontext an — kein Aufschlag mehr.">
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
</span>
<span className="font-mono font-bold text-foreground">
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", pct >= 85 ? "bg-red-500" : pct >= 65 ? "bg-amber-500" : "bg-teal-500")} style={{ width: `${pct}%` }} />
</div>
{bud && !bud.fits && (
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) wird der Speicher knapp. Das Set
bleibt dabei immer geladen — wird es wirklich eng, schlägt das Laden des großen Modells fehl (es wartet dann,
statt das Set zu verdrängen).
</span>
</p>
)}
{bud && bud.fits && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-400">
<Check className="h-3.5 w-3.5 shrink-0" /> Passt auch neben dem größten Gelegenheits-Modell — nichts wird verdrängt.
</p>
)}
</div>
{/* Auswahl-Modal */}
{picking && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Modell zum Immer-bereit-Set hinzufügen">
<div className="w-full max-w-md space-y-3 rounded-2xl border border-border/80 bg-card p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">Dauerhaft bereithalten</h3>
<button onClick={() => setPicking(false)} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<p className="text-xs text-muted-foreground">Wähle ein Modell, das ohne Ladezeit bereitstehen soll:</p>
<div className="max-h-60 space-y-1.5 overflow-y-auto pr-1">
{eligible.map((m) => (
<button
key={m.name}
onClick={() => add(m.name)}
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/30 bg-background/20 px-3 py-2.5 text-left transition-colors hover:bg-accent cursor-pointer"
>
<span className="flex min-w-0 items-center gap-2">
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
<span className="truncate font-mono text-xs font-semibold text-foreground">{shortName(m.name)}</span>
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
</button>
))}
</div>
</div>
</div>
)}
{dialogElement}
</div>
)
}
function Header() {
return (
<div className="flex items-center gap-2">
<Zap className="h-4.5 w-4.5 text-primary" />
<div>
<h2 className="text-sm font-bold uppercase tracking-wide text-foreground">Immer-bereit-Set</h2>
<p className="text-[11px] text-muted-foreground">Diese Modelle hält Lucy immer bereit — sie antworten ohne Ladezeit.</p>
</div>
</div>
)
}
@@ -1,119 +0,0 @@
import { useMemo, useRef, useState } from "react"
import { AlertTriangle, RefreshCw, Search, Terminal } from "lucide-react"
import { cn } from "@/lib/utils"
// System-Log-Konsole (Paket D16c): färbt Fehler/Warnungen ein, filtert auf „nur Probleme"
// und durchsucht die Zeilen. Ersetzt den früheren monochromen <pre>-Dump („zu dünn").
type Level = "error" | "warn" | "info"
// Wortgrenzen-Regex, damit „error" nicht in jeder URL/jedem Pfad anschlägt. Deutsch + Englisch,
// weil unsere Dienste beides loggen (Hermes englisch, MC2/Voice teils deutsch).
const _ERR = /\b(error|fehler|fail(ed|ure)?|fatal|critical|crit|panic|traceback|exception|denied|refused|unable|emerg|alert|oom|killed)\b/i
const _WARN = /\b(warn(ing)?|deprecat\w*|retry(ing)?|timeout|timed out|degraded|missing|not found|nicht gefunden)\b/i
function classify(line: string): Level {
if (_ERR.test(line)) return "error"
if (_WARN.test(line)) return "warn"
return "info"
}
const LEVEL_CLS: Record<Level, string> = {
error: "text-red-400",
warn: "text-amber-300",
info: "text-cyan-300/80",
}
export function LogConsole({ service, text, loading, logStatus, onRefresh, onOpenSettings }: {
service: string
text: string
loading: boolean
logStatus: string | null
onRefresh: () => void
onOpenSettings: () => void
}) {
const [problemsOnly, setProblemsOnly] = useState(false)
const [query, setQuery] = useState("")
const containerRef = useRef<HTMLDivElement | null>(null)
const lines = useMemo(
() => (text ? text.split("\n").map((t) => ({ t, level: classify(t) })) : []),
[text],
)
const problemCount = useMemo(() => lines.filter((l) => l.level !== "info").length, [lines])
const q = query.trim().toLowerCase()
const shown = lines.filter(
(l) => (!problemsOnly || l.level !== "info") && (!q || l.t.toLowerCase().includes(q)),
)
const needsPw = logStatus === "password_required" || logStatus === "incorrect_password"
return (
<div className="flex-1 flex flex-col min-h-[300px] border border-border/60 bg-black/50 rounded-xl overflow-hidden shadow-inner">
{/* Konsolen-Kopf: Titel + Problem-Filter + Suche + Neu laden */}
<div className="flex h-10 items-center justify-between gap-2 px-3 border-b border-border/40 bg-black/30 shrink-0">
<div className="flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground shrink-0">
<Terminal className="h-3 w-3 text-primary" />
<span className="hidden sm:inline">stdout/stderr —</span> {service}
</div>
<div className="flex items-center gap-1.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="filtern…"
className="h-7 w-24 sm:w-36 rounded-md border border-border/50 bg-background/40 pl-6 pr-2 text-[10px] text-foreground outline-none focus:border-primary/50"
/>
</div>
<button
onClick={() => setProblemsOnly((v) => !v)}
title="Nur Fehler und Warnungen zeigen"
className={cn("flex h-7 items-center gap-1 rounded-md border px-2 text-[10px] font-semibold transition-colors",
problemsOnly ? "border-amber-500/50 bg-amber-500/15 text-amber-300"
: "border-border/50 bg-background/20 text-muted-foreground hover:text-foreground")}
>
<AlertTriangle className="h-3 w-3" />
Nur Probleme{problemCount > 0 ? ` (${problemCount})` : ""}
</button>
<button onClick={onRefresh} disabled={loading} title="Neu laden"
className="flex h-7 w-7 items-center justify-center rounded-md border border-border/50 bg-background/20 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
</button>
</div>
</div>
{/* Zeilen-Bereich */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 text-[10px] font-mono leading-relaxed scrollbar-thin select-text">
{needsPw ? (
<div className="flex h-full flex-col items-center justify-center space-y-3 p-6 text-center">
<AlertTriangle className="h-8 w-8 text-amber-400 animate-pulse animate-duration-1000" />
<div className="text-xs font-semibold text-amber-300">
{logStatus === "incorrect_password" ? "Falsches Sudo-Passwort hinterlegt." : "Sudo-Passwort für systemd-Dienste erforderlich."}
</div>
<p className="max-w-xs text-[10px] leading-normal text-muted-foreground">
Für das Auslesen der systemd-Logs von {service} werden Root-Rechte benötigt. Hinterlege dein Passwort in den Einstellungen.
</p>
<button onClick={onOpenSettings}
className="mt-2 rounded-md bg-primary px-3 py-1.5 text-[10px] font-semibold text-primary-foreground transition-colors hover:bg-primary/95">
Sudo-Passwort eintragen
</button>
</div>
) : loading && !text ? (
<span className="text-muted-foreground">Lade Logs…</span>
) : lines.length === 0 ? (
<span className="text-muted-foreground">Keine Logeinträge vorhanden.</span>
) : shown.length === 0 ? (
<span className="text-muted-foreground">
{problemsOnly ? "Keine Fehler oder Warnungen — der Dienst läuft sauber. ✓" : "Kein Treffer für den Filter."}
</span>
) : (
shown.map((l, i) => (
<div key={i} className={cn("whitespace-pre-wrap", LEVEL_CLS[l.level])}>{l.t || " "}</div>
))
)}
</div>
</div>
)
}
@@ -1,182 +0,0 @@
import { useEffect, useState } from "react"
import { Eye, EyeOff, Key, Loader2, ShieldCheck } from "lucide-react"
import { api } from "@/lib/api"
// Einstellungen-Tab des SystemDrawers.
//
// v3-Umbau P1 (28.08.2026): Hier standen zwei Geheimnisse, die im localStorage des
// Browsers lagen und bei jedem mutierenden Request mitreisten.
//
// · Das Box-Sudo-Passwort ist ERSATZLOS weg. Auf der Box gemessen: `sudo -n true`
// laeuft durch, weil /etc/sudoers den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt.
// Das Feld hat also nie etwas bewirkt — es war reines Risiko (ein XSS in dieser
// SPA haette Root bedeutet).
// · Der HuggingFace-Token liegt jetzt auf der Box (services/geheimnisse.py, 0600).
// Diese Ansicht erfaehrt nur noch, OB einer gesetzt ist — nie seinen Wert. Deshalb
// gibt es kein "Anzeigen" fuer einen gespeicherten Token, sondern nur Ersetzen
// oder Loeschen.
interface GeheimnisStatus {
hf_token_gesetzt: boolean
hf_token_aus_env: boolean
schreibbar: boolean
}
export function SettingsTab({ open, showAlert }: {
open: boolean
showAlert: (title: string, message: string) => void
}) {
const [status, setStatus] = useState<GeheimnisStatus | null>(null)
const [eingabe, setEingabe] = useState("")
const [sichtbar, setSichtbar] = useState(false)
const [laedt, setLaedt] = useState(false)
const [arbeitet, setArbeitet] = useState(false)
async function statusLaden() {
setLaedt(true)
try {
setStatus(await api<GeheimnisStatus>("/api/maintenance/geheimnisse"))
} catch (e: any) {
showAlert("Nicht erreichbar", `Der Geheimnis-Zustand konnte nicht geladen werden: ${e?.message || e}`)
} finally {
setLaedt(false)
}
}
useEffect(() => {
if (open) {
setEingabe("")
setSichtbar(false)
void statusLaden()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
async function setzen(wert: string | null) {
setArbeitet(true)
try {
const r = await api<GeheimnisStatus>("/api/maintenance/geheimnisse", {
method: "POST",
body: JSON.stringify({ schluessel: "hf_token", wert }),
})
setStatus(r)
setEingabe("")
showAlert(wert ? "Gespeichert" : "Gelöscht",
wert ? "Der Token liegt jetzt auf der Box — der Browser behält ihn nicht."
: "Der Token wurde von der Box entfernt.")
} catch (e: any) {
showAlert("Fehlgeschlagen", e?.message || String(e))
} finally {
setArbeitet(false)
}
}
return (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten &amp; Schlüssel</h3>
<p className="text-[10px] leading-normal text-muted-foreground">
Geheimnisse liegen auf der Box (Datei mit Rechten 0600), nicht im Browser. Diese Seite
zeigt nur, ob etwas gesetzt ist — den Wert bekommt sie nie zurück.
</p>
</div>
{/* Sudo: bewusst nur noch eine Erklaerung, kein Eingabefeld. */}
<div className="mc-card-sm space-y-1.5 p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold">
<ShieldCheck className="h-4 w-4 text-emerald-400" aria-hidden="true" />
Box-Sudo-Passwort — nicht mehr nötig
</div>
<p className="text-[10px] leading-normal text-muted-foreground">
Systemdienste, OS-Update und Neustart laufen passwortlos (<code className="font-mono text-primary">NOPASSWD</code> in
<code className="font-mono"> /etc/sudoers</code>). Das frühere Feld hat nie etwas bewirkt und lag dabei
im Browser-Speicher — es ist am 28.08.2026 ersatzlos entfallen. Verlangt <code className="font-mono">sudo</code> auf
der Box je doch ein Passwort, sagt die Wartung das im Klartext; das ist dann dort zu lösen.
</p>
</div>
{/* HuggingFace-Token */}
<div className="space-y-2">
<label htmlFor="hf-token" className="flex items-center gap-1.5 text-xs font-semibold">
<Key className="h-4 w-4 text-violet-400" aria-hidden="true" />
HuggingFace-Token
</label>
<div className="flex items-center gap-2 text-[11px]">
{laedt ? (
<span className="flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" /> Zustand wird geladen …
</span>
) : status?.hf_token_gesetzt ? (
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
<span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
Gesetzt{status.hf_token_aus_env ? " (aus der Dienst-Umgebung)" : ""}
</span>
) : (
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-muted-foreground/50" aria-hidden="true" />
Nicht gesetzt
</span>
)}
</div>
{status?.hf_token_aus_env && (
<p className="text-[9px] leading-normal text-amber-400/90">
Der Token kommt aus der Umgebung des Dienstes und hat Vorrang. Hier gespeicherte Werte
bleiben wirkungslos, solange <code className="font-mono">HF_TOKEN</code> gesetzt ist.
</p>
)}
{status && !status.schreibbar && (
<p className="text-[9px] leading-normal text-amber-400/90">
Die Ablage ist nicht beschreibbar — auf einem Entwicklungsrechner ohne den Modell-Ordner
der Box ist das normal. Speichern würde hier ins Leere laufen.
</p>
)}
<div className="relative">
<input
id="hf-token"
type={sichtbar ? "text" : "password"}
value={eingabe}
onChange={(e) => setEingabe(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder={status?.hf_token_gesetzt ? "Neuen Token eingeben, um zu ersetzen" : "hf_…"}
className="h-10 w-full rounded-lg border border-border/60 bg-background/40 pl-3 pr-10 text-xs text-foreground outline-none transition-colors focus:border-primary"
/>
<button
type="button"
onClick={() => setSichtbar(!sichtbar)}
aria-label={sichtbar ? "Token verbergen" : "Token anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground transition-colors hover:text-foreground"
>
{sichtbar ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] leading-normal text-muted-foreground">
Nötig für Modelle mit Zugangsschranke. Wird beim Download als
<code className="font-mono"> HF_TOKEN</code> an den Job gereicht und verlässt die Box nicht.
</p>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => setzen(eingabe.trim())}
disabled={arbeitet || !eingabe.trim()}
className="flex h-9 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-semibold text-primary-foreground shadow shadow-primary/10 transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{arbeitet && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
Auf der Box speichern
</button>
<button
onClick={() => setzen(null)}
disabled={arbeitet || !status?.hf_token_gesetzt}
className="flex h-9 cursor-pointer items-center justify-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
>
Löschen
</button>
</div>
</div>
)
}
@@ -1,196 +0,0 @@
import { AlertTriangle, ArrowRight, Bot, CheckCircle2, Clock, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, ShieldCheck, 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>
) : (
<>
{/* Aktions-Verdikt in Lucys Stimme — "Musst du etwas tun?" (engine/swap/hermes) */}
{d?.action_needed != null && (
<div className={cn("flex items-start gap-2 rounded-lg border p-3",
d.action_needed ? "border-amber-500/40 bg-amber-500/10" : "border-emerald-500/40 bg-emerald-500/10")}>
{d.action_needed
? <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
: <CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />}
<div className="min-w-0">
<div className={cn("text-xs font-semibold", d.action_needed ? "text-amber-300" : "text-emerald-300")}>
Musst du etwas tun? {d.action_needed ? "Ja" : "Nein — die Box regelt das (Fangnetz)"}
</div>
{d.action_needed && d.action_text && (
<div className="mt-0.5 text-[11px] text-foreground/90">{d.action_text}</div>
)}
</div>
</div>
)}
{/* Zusammenfassung der Box (Breaking Changes zuerst) */}
{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>
)}
{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>
</>
)}
{/* Ehrlich: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) */}
{(d?.held_back?.length ?? 0) > 0 && (
<div className="space-y-1.5 rounded-lg border border-border/40 bg-background/20 p-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> Vom Hersteller zurückgestellt — kein Handeln nötig
</div>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Pakete gäbe es bereits, Ubuntu spielt sie aber gestaffelt aus (Phasen-Rollout)
bzw. hält sie kurz zurück. Sie kommen bei einem der nächsten automatischen Läufe von
selbst — das ist kein Fehler und nichts hängt fest.
</p>
<div className="space-y-1">
{d!.held_back!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/30 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-muted-foreground shrink-0" />{p.name}
</span>
<span className="text-[9px] text-muted-foreground shrink-0">
{p.reason === "phasing" ? "Phasen-Rollout" : "vorerst zurückgehalten"}
</span>
</div>
))}
</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">
Original-Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
</a>
)}
{d?.body && (
<details className="group">
<summary className="cursor-pointer text-[10px] text-muted-foreground hover:text-foreground">Original-Notizen (englisch) anzeigen</summary>
<pre className="mt-1.5 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>
</details>
)}
</>
) : (
// 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>
</>
)
)}
{/* Fangnetz-Hinweis: verheiratet Breaking-Change-Sorge mit dem Postcheck (engine/swap/hermes) */}
{detail.kind !== "os" && !nothing && (
<div className="flex items-start gap-2 rounded-lg border border-border/40 bg-background/20 p-2.5">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-muted-foreground leading-normal">
Vor dem Update sichert die Box automatisch den alten Stand. Danach prüft sie den ganzen
Stack per echter Anfrage — läuft etwas nicht, rollt sie von selbst zurück{detail.kind === "hermes" ? " und startet den Gateway neu" : ""}.
</p>
</div>
)}
</>
)}
</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>
)
}
-44
View File
@@ -1,44 +0,0 @@
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).
// Die frühere SERVICES-Konstante (UI-Kopie der Dienste-Liste) ist weg: der Drawer rendert
// jetzt direkt die Backend-Antwort von /api/system/services — EINE Quelle der Wahrheit.
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>
)
}
@@ -1,12 +0,0 @@
import type { LucideIcon } from "lucide-react"
// Kleine Abschnitts-Überschrift innerhalb einer Seite. Lag vorher in AuftragsbuchView;
// seit der Ideen-Queue eine eigene Seite hat (25.07.2026) brauchen beide sie — also
// einmal hier statt zweimal dupliziert.
export function SectionLabel({ icon: Icon, children }: { icon: LucideIcon; children: React.ReactNode }) {
return (
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Icon className="h-3.5 w-3.5" /> {children}
</p>
)
}
+54 -32
View File
@@ -1,38 +1,60 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
type Variant = "default" | "outline" | "ghost"
type Size = "default" | "sm" | "icon"
// shadcn-Knopf im Cockpit-Stil: Instrumenten-Schrift, mindestens 44 px hoch (Touch am Handy).
// default = Bernstein (die eine Hauptaktion), outline = neutral, info = Cyan, gefahr = Rot.
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-transparent font-anzeige font-semibold uppercase tracking-[0.12em] whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-bernstein text-primary-foreground hover:bg-bernstein-hell",
outline: "border-linie-stark bg-erhaben text-foreground hover:border-text-3 hover:bg-[#232931]",
info: "border-cyan-rand bg-transparent text-cyan hover:bg-cyan-grund",
gefahr: "border-rot-rand bg-rot-grund text-rot-text hover:bg-[#3a1512]",
ghost: "text-text-2 hover:bg-erhaben hover:text-foreground",
link: "px-0 text-cyan normal-case tracking-normal underline-offset-4 hover:underline",
},
size: {
default: "h-11 px-5 text-[15px]",
sm: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
icon: "size-11",
// Schließen-Knopf von Dialog und Schublade (shadcn) — ebenfalls 44 px für Touch.
"icon-sm": "size-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
const variants: Record<Variant, string> = {
default: "bg-primary text-primary-foreground hover:opacity-90",
outline: "border border-border bg-transparent hover:bg-accent hover:text-accent-foreground",
ghost: "bg-transparent hover:bg-accent hover:text-accent-foreground",
}
const sizes: Record<Size, string> = {
default: "h-9 px-4 py-2 text-sm",
sm: "h-8 px-3 text-xs",
icon: "h-9 w-9",
}
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
size?: Size
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
),
)
Button.displayName = "Button"
)
}
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }
+166
View File
@@ -0,0 +1,166 @@
import * as React from "react"
import { cn } from "cn"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Schließen</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Schließen</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+147
View File
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Dialog as SheetPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Schließen</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -1,99 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { AktivitaetResp } from "@/lib/api"
// Die Query wird ersetzt, nicht das Netz: Der Test prüft die ANSICHT, nicht den Abruf.
const useWerkzeugverlauf = vi.hoisted(() => vi.fn())
vi.mock("@/lib/queries", () => ({ useWerkzeugverlauf }))
const { Werkzeugverlauf } = await import("./Werkzeugverlauf")
function zeigen(data: AktivitaetResp | undefined, isLoading = false) {
useWerkzeugverlauf.mockReturnValue({ data, isLoading })
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<Werkzeugverlauf />
</QueryClientProvider>,
)
}
const ANTWORT: AktivitaetResp = {
verfuegbar: true,
pfad: "~/.hermes/logs/agent.log",
rufe: [
{ zeit: "2026-08-28T07:01:20", lauf: "cron_a", werkzeug: "web_extract", dauer_s: 0.17, zeichen: null, ok: false, fehler: "DuckDuckGo (ddgs) is a search-only backend." },
{ zeit: "2026-08-28T07:02:36", lauf: null, werkzeug: "web_search", dauer_s: 2.61, zeichen: 2944, ok: true, fehler: null },
{ zeit: "2026-08-28T07:03:18", lauf: "cron_a", werkzeug: "terminal", dauer_s: 1.4, zeichen: 53, ok: true, fehler: null },
],
werkzeuge: [
{ werkzeug: "web_extract", rufe: 2, fehler: 2, dauer_schnitt_s: 0.17, letzter_fehler: "DuckDuckGo (ddgs) is a search-only backend." },
{ werkzeug: "web_search", rufe: 20, fehler: 0, dauer_schnitt_s: 1.77, letzter_fehler: null },
],
laeufe: [{ lauf: "cron_a", von: "2026-08-28T07:01:05", bis: "2026-08-28T07:03:18", rufe: 3, fehler: 1 }],
}
/** Die Bilanz-Karte eines Werkzeugs — der Name steht auch in der Zeitleiste, deshalb
* wird hier gezielt die Karte gesucht und nicht irgendein Vorkommen des Namens. */
function bilanzKarte(werkzeug: string): HTMLElement {
const treffer = screen.getAllByText(werkzeug)
.map((el) => el.closest("div.mc-card-sm"))
.filter((el): el is HTMLElement => el instanceof HTMLElement && el.textContent!.includes("Rufe"))
expect(treffer.length, `genau eine Bilanz-Karte fuer ${werkzeug}`).toBe(1)
return treffer[0]
}
describe("Werkzeugverlauf", () => {
// Der eigentliche Zweck der Ansicht: Ein Werkzeug, das IMMER scheitert, verschwindet
// in einer Ereignisliste. Genau so lief `web_extract` vier Tage lang jeden Morgen
// ins Leere, ohne dass es jemandem auffiel.
it("markiert ein durchgehend scheiterndes Werkzeug als „immer rot“", () => {
zeigen(ANTWORT)
const karte = bilanzKarte("web_extract")
expect(karte.textContent).toMatch(/immer rot/i)
expect(karte.textContent).toMatch(/2 Fehler/)
})
it("markiert ein fehlerfreies Werkzeug NICHT", () => {
zeigen(ANTWORT)
const karte = bilanzKarte("web_search")
expect(karte.textContent).not.toMatch(/immer rot/i)
expect(karte.textContent).toMatch(/0 Fehler/)
// Und die Markierung gibt es im ganzen Abschnitt genau einmal.
expect(screen.getAllByText(/immer rot/i)).toHaveLength(1)
})
it("zeigt den echten Grund des Fehlers, nicht nur „Fehler“", () => {
zeigen(ANTWORT)
expect(bilanzKarte("web_extract").textContent).toMatch(/search-only backend/)
})
it("nennt Rufe und Fehler in der Abschnitts-Überschrift", () => {
const { container } = zeigen(ANTWORT)
// Die Überschrift setzt sich aus mehreren Textknoten zusammen (der Fehler-Teil
// erscheint nur bedingt), deshalb gegen den zusammengesetzten Text prüfen.
const kopf = container.querySelector("section")!.textContent!
expect(kopf).toMatch(/3 Rufe/) // 3 Einträge in ANTWORT.rufe
expect(kopf).toMatch(/2 Fehler/) // Summe aus der Werkzeug-Bilanz
})
it("gruppiert nach Lauf und zeigt dessen Zeitspanne", () => {
zeigen(ANTWORT)
const zeile = screen.getByText("cron_a").closest("div")!
expect(zeile.textContent).toMatch(/07:01:05–07:03:18/)
expect(zeile.textContent).toMatch(/1 Fehler/)
})
// Auf einem Entwicklungsrechner ohne Hermes gibt es das Log nicht. Dann still
// weglassen — eine leere Karte waere eine Zusage ohne Inhalt.
it("verschwindet ganz, wenn es kein Hermes-Log gibt", () => {
const { container } = zeigen({ verfuegbar: false, pfad: "", rufe: [], werkzeuge: [], laeufe: [] })
expect(container).toBeEmptyDOMElement()
})
it("sagt beim Laden, was es tut", async () => {
zeigen(undefined, true)
await waitFor(() => expect(screen.getByText(/Lese Hermes/)).toBeInTheDocument())
})
})
@@ -1,138 +0,0 @@
import { AlertTriangle, Activity, CheckCircle2, Clock, Wrench } from "lucide-react"
import { useWerkzeugverlauf } from "@/lib/queries"
import { SectionLabel } from "@/components/ui/SectionLabel"
import { cn } from "@/lib/utils"
// Werkzeug-Verlauf des Agenten (v3-Umbau P6).
//
// Der Blueprint sah hier eine „Live Agent Matrix" mit Denkstrom vor (§4.4). Die ist so
// nicht baubar: Lucys Denkschritte entstehen im Hermes-Prozess, und AGENTS.md verbietet
// es, dessen Quellcode zu patchen. Was aber offen dalag: Hermes protokolliert JEDEN
// Werkzeug-Ruf in sein Log. Zuschauen ist kein Patchen.
//
// Diese Ansicht zeigt deshalb nur, was wirklich da ist — welches Werkzeug, wie lange,
// mit welchem Ergebnis, in welchem Lauf. Kein erfundener Denkstrom, keine leere Leitung.
//
// Die BILANZ steht oben, nicht die Zeitleiste. Grund: Ein Werkzeug, das immer scheitert,
// verschwindet in einer Liste von Ereignissen — in der Zeile „web_extract · 2 Rufe ·
// 2 Fehler" nicht. Genau so ist beim Bauen aufgefallen, dass `web_extract` seit
// mindestens vier Tagen jeden Morgen um 07:00 scheitert.
const uhr = (iso: string) => iso.slice(11, 19)
/** Dauer lesbar: unter einer Sekunde in ms, sonst in Sekunden. */
function dauer(s: number): string {
return s < 1 ? `${Math.round(s * 1000)} ms` : `${s.toFixed(1)} s`
}
export function Werkzeugverlauf() {
const { data, isLoading } = useWerkzeugverlauf()
if (isLoading) {
return (
<section className="space-y-3">
<SectionLabel icon={Activity}>Werkzeug-Verlauf</SectionLabel>
<div className="mc-card-sm p-4 text-xs text-muted-foreground">Lese Hermes' Log …</div>
</section>
)
}
// Auf einem Entwicklungsrechner ohne Hermes gibt es das Log nicht. Dann still
// weglassen statt eine leere Karte zu zeigen — wie der Rest der Seite es hält.
if (!data?.verfuegbar) return null
const { rufe, werkzeuge, laeufe } = data
const fehlerhaft = werkzeuge.filter((w) => w.fehler > 0)
return (
<section className="space-y-3">
<SectionLabel icon={Activity}>
Werkzeug-Verlauf · {rufe.length} Rufe
{fehlerhaft.length > 0 && ` · ${fehlerhaft.reduce((n, w) => n + w.fehler, 0)} Fehler`}
</SectionLabel>
<p className="max-w-2xl text-[11px] leading-relaxed text-muted-foreground">
Gelesen aus Hermes' eigenem Log — Mission Control schaut zu, greift aber nicht ein.
Was dort <em>nicht</em> steht, steht auch hier nicht: Lucys Gedankengang und die
Argumente eines Werkzeug-Rufs bleiben unsichtbar.
</p>
{/* Bilanz zuerst — hier faellt ein Dauerfehler auf, den eine Zeitleiste verschluckt. */}
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{werkzeuge.map((w) => {
const immerRot = w.fehler > 0 && w.fehler === w.rufe
return (
<div
key={w.werkzeug}
className={cn("mc-card-sm space-y-1.5 p-3",
immerRot && "border-red-500/40 bg-red-500/5")}
>
<div className="flex items-center gap-2">
<Wrench className={cn("h-3.5 w-3.5 shrink-0",
immerRot ? "text-red-400" : w.fehler ? "text-amber-400" : "text-primary")} aria-hidden="true" />
<span className="truncate font-mono text-xs font-semibold text-foreground">{w.werkzeug}</span>
{immerRot && (
<span className="ml-auto shrink-0 rounded bg-red-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-red-300">
immer rot
</span>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-[10px] text-muted-foreground">
<span className="tabular-nums">{w.rufe} Rufe</span>
<span className={cn("tabular-nums", w.fehler > 0 && "font-semibold text-red-400")}>
{w.fehler} Fehler
</span>
<span className="tabular-nums">Ø {dauer(w.dauer_schnitt_s)}</span>
</div>
{w.letzter_fehler && (
<p className="border-t border-border/30 pt-1.5 text-[10px] leading-relaxed text-red-300/90">
{w.letzter_fehler}
</p>
)}
</div>
)
})}
</div>
{/* Laeufe: welcher Cron/welche Sitzung hat wann was getan. */}
{laeufe.length > 0 && (
<div className="mc-card-sm divide-y divide-border/30">
{laeufe.map((l) => (
<div key={l.lauf} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 font-mono text-[10px]">
<Clock className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="truncate font-semibold text-foreground">{l.lauf}</span>
<span className="tabular-nums text-muted-foreground">{uhr(l.von)}–{uhr(l.bis)}</span>
<span className="ml-auto tabular-nums text-muted-foreground">{l.rufe} Rufe</span>
{l.fehler > 0 && (
<span className="tabular-nums font-semibold text-red-400">{l.fehler} Fehler</span>
)}
</div>
))}
</div>
)}
{/* Zeitleiste, juengster Ruf oben. */}
<details className="mc-card-sm">
<summary className="cursor-pointer px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground">
Einzelne Rufe ({rufe.length})
</summary>
<ul className="max-h-72 divide-y divide-border/20 overflow-y-auto scrollbar-thin border-t border-border/30">
{[...rufe].reverse().map((r, i) => (
<li key={`${r.zeit}-${r.werkzeug}-${i}`} className="flex items-start gap-2 px-3 py-1.5 font-mono text-[10px]">
{r.ok
? <CheckCircle2 className="mt-px h-3 w-3 shrink-0 text-emerald-500" aria-hidden="true" />
: <AlertTriangle className="mt-px h-3 w-3 shrink-0 text-red-400" aria-hidden="true" />}
<span className="shrink-0 tabular-nums text-muted-foreground">{uhr(r.zeit)}</span>
<span className="shrink-0 font-semibold text-foreground">{r.werkzeug}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">{dauer(r.dauer_s)}</span>
{r.zeichen != null && (
<span className="shrink-0 tabular-nums text-muted-foreground/60">{r.zeichen} Z.</span>
)}
{r.fehler && <span className="min-w-0 flex-1 truncate text-red-300/90" title={r.fehler}>{r.fehler}</span>}
</li>
))}
</ul>
</details>
</section>
)
}
@@ -1,59 +0,0 @@
import { describe, expect, it } from "vitest"
import { render } from "@testing-library/react"
import { Dichteleiste } from "./Dichteleiste"
import type { ChronikItem } from "@/lib/api"
function eintrag(ts: number): ChronikItem {
return { id: ts, ts, subject: "x", text: "y", source: "test", priority: "normal" }
}
/** Die Balken der Leiste (ohne die Achsen-Beschriftung darunter). */
function balken(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll('[role="img"] > div')] as HTMLElement[]
}
describe("Dichteleiste", () => {
it("zeigt nichts, wenn es nichts zu zeigen gibt", () => {
const { container } = render(<Dichteleiste items={[]} />)
expect(container).toBeEmptyDOMElement()
})
it("verteilt die Einträge über die volle Breite", () => {
const basis = 1_800_000_000
const items = [eintrag(basis), eintrag(basis + 43_200), eintrag(basis + 86_400)]
const { container } = render(<Dichteleiste items={items} />)
const b = balken(container)
expect(b).toHaveLength(48)
// Erster, mittlerer und letzter Eimer sind belegt — dazwischen ist es leer.
expect(b[0].style.height).not.toBe("2px")
expect(b[47].style.height).not.toBe("2px")
expect(b[5].style.height).toBe("2px")
})
// Ohne Mindesthöhe sähe ein ruhiger Tag aus wie gar keine Aktivität — genau die
// Verwechslung, die die Leiste verhindern soll.
it("macht auch einen einzelnen Eintrag sichtbar", () => {
const { container } = render(<Dichteleiste items={[eintrag(1_800_000_000)]} />)
const belegt = balken(container).filter((d) => d.style.height !== "2px")
expect(belegt).toHaveLength(1)
expect(parseFloat(belegt[0].style.height)).toBeGreaterThanOrEqual(12)
})
it("beschriftet sich für Screenreader mit Anzahl und Zeitraum", () => {
const basis = 1_800_000_000
const { container } = render(<Dichteleiste items={[eintrag(basis), eintrag(basis + 86_400)]} />)
const label = container.querySelector('[role="img"]')!.getAttribute("aria-label")!
expect(label).toMatch(/2 Einträge/)
expect(label).toMatch(/zwischen .* und /)
})
it("hebt die Leiste hervor, solange gefiltert wird", () => {
const items = [eintrag(1_800_000_000)]
const normal = render(<Dichteleiste items={items} />).container
const gefiltert = render(<Dichteleiste items={items} aktiv />).container
const klasse = (c: HTMLElement) =>
balken(c).find((d) => d.style.height !== "2px")!.className
expect(klasse(normal)).toMatch(/primary\/40/)
expect(klasse(gefiltert)).toMatch(/primary\/70/)
})
})
@@ -1,74 +0,0 @@
import { useMemo } from "react"
import type { ChronikItem } from "@/lib/api"
import { cn } from "@/lib/utils"
// Aktivitäts-Dichte über der Chronik (v3-Umbau P6, Spezifikation §4.5).
//
// Der eigentliche Zugewinn gegenüber einer reinen Liste: Sie zeigt, WANN die Box aktiv
// war, bevor man liest, WAS sie getan hat. Ein stiller Tag und eine durchgearbeitete
// Nacht sehen in einer Liste gleich aus — man muss scrollen, um es zu merken.
//
// Bewusst eigenes SVG statt einer Diagramm-Bibliothek: Es sind Rechtecke über einer
// Zeitachse. Recharts dafür zu laden wäre 95 kB für etwas, das in 30 Zeilen passt —
// und genau dieser Reflex hat in P0 das Startbündel aufgebläht.
const STUNDE = 3_600
const EIMER = 48 // 48 Stufen über den gezeigten Zeitraum
export function Dichteleiste({ items, aktiv }: {
items: ChronikItem[]
/** Farbe für den Fall, dass gefiltert wird — dann zeigt die Leiste nur die Treffer. */
aktiv?: boolean
}) {
const { balken, von, bis, max } = useMemo(() => {
if (!items.length) return { balken: [] as number[], von: 0, bis: 0, max: 0 }
// Die Chronik kommt neueste zuerst; für die Achse brauchen wir beide Enden.
const zeiten = items.map((i) => i.ts)
const von = Math.min(...zeiten)
const bis = Math.max(...zeiten)
const spanne = Math.max(bis - von, STUNDE)
const balken = new Array(EIMER).fill(0)
for (const t of zeiten) {
const i = Math.min(EIMER - 1, Math.floor(((t - von) / spanne) * EIMER))
balken[i]++
}
return { balken, von, bis, max: Math.max(...balken, 1) }
}, [items])
if (balken.length === 0) return null
const zeitpunkt = (ts: number) =>
new Date(ts * 1000).toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit",
...(bis - von < 2 * 86_400 ? { hour: "2-digit", minute: "2-digit" } : {}),
})
return (
<div className="space-y-1">
<div
className="flex h-8 items-end gap-px"
role="img"
aria-label={`Aktivitätsdichte: ${items.length} Einträge zwischen ${zeitpunkt(von)} und ${zeitpunkt(bis)}`}
>
{balken.map((n, i) => (
<div
key={i}
title={n === 1 ? "1 Eintrag" : `${n} Einträge`}
className={cn(
"min-w-px flex-1 rounded-t-sm transition-colors",
n === 0 ? "bg-border/25" : aktiv ? "bg-primary/70" : "bg-primary/40",
)}
// Auch ein einzelner Eintrag muss sichtbar sein — sonst sieht ein ruhiger
// Tag aus wie gar nichts.
style={{ height: n === 0 ? "2px" : `${Math.max(12, (n / max) * 100)}%` }}
/>
))}
</div>
<div className="flex justify-between font-mono text-[9px] text-muted-foreground/60">
<span>{zeitpunkt(von)}</span>
<span className="tabular-nums">{items.length} Einträge</span>
<span>{zeitpunkt(bis)}</span>
</div>
</div>
)
}
@@ -1,55 +0,0 @@
// Ergebnis-Panel einer abgeschlossenen Idee: was die Box getan hat, in ihren Worten.
//
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
import { useEffect, useState } from "react"
import { ClipboardCheck } from "lucide-react"
import { api, type IdeenErgebnis } from "@/lib/api"
export function ErgebnisPanel({ id }: { id: string }) {
const [erg, setErg] = useState<IdeenErgebnis | null>(null)
const [fehler, setFehler] = useState(false)
// Fehler darf nicht kleben: fällt der erste Versuch in ein Deploy-Fenster
// (Zentrale startet gerade neu), muss ein Klick neu laden können (Vorfall 16.07.).
const [versuch, setVersuch] = useState(0)
useEffect(() => {
let weg = false
setFehler(false)
setErg(null)
api<IdeenErgebnis>(`/api/ideen/${id}/ergebnis`)
.then((r) => { if (!weg) setErg(r) })
.catch(() => { if (!weg) setFehler(true) })
return () => { weg = true }
}, [id, versuch])
const text = erg ? (erg.summary || erg.result) : ""
return (
<div className="ml-2 -mt-1">
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/5 p-2.5">
<p className="mb-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-emerald-300">
<ClipboardCheck className="h-3 w-3" /> Ergebnis des Workers
</p>
{fehler ? (
<button
type="button"
onClick={() => setVersuch((v) => v + 1)}
className="text-[10px] text-red-400 underline decoration-dotted underline-offset-2 hover:text-red-300"
>
Ergebnis nicht ladbar — nochmal versuchen
</button>
) : erg === null ? (
<p className="text-[10px] text-muted-foreground animate-pulse">Wird geladen …</p>
) : text ? (
<p className="max-h-64 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{text}</p>
) : (
<p className="text-[10px] text-muted-foreground italic">
Der Worker hat keine Abschluss-Zusammenfassung hinterlassen (ältere Karte oder Protokollverstoß).
</p>
)}
</div>
</div>
)
}
// Die Seite selbst: Kopf + die Queue. Bewusst duenn — die Logik lebt unveraendert in
// IdeenSection, damit dieser Umzug reines Verschieben bleibt und nichts still kippt.
@@ -1,676 +0,0 @@
import { useEffect, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import {
Loader2, AlertTriangle, ChevronDown, ChevronRight, Clock, Hammer, Lightbulb, Send,
HelpCircle, RotateCcw, Square, ArrowUp, ArrowDown, Code2, FileText, GitFork, X, Check,
} from "lucide-react"
import { api, type IdeenItem } from "@/lib/api"
import { useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { fmtWhen } from "@/lib/format"
import { SectionLabel } from "@/components/ui/SectionLabel"
import { AKTIV_RANG, ART_UI, FRAGE_HINT, IDEEN_STATE } from "./zustaende"
import { quoteLesen, quoteZaehlen } from "./quote"
import { ErgebnisPanel } from "./ErgebnisPanel"
import { KonzeptPanel } from "./KonzeptPanel"
import { ProjektGruppe } from "./ProjektGruppe"
import { Wahl } from "./Wahl"
// Ideen-Queue (natives Hermes-Kanban) — eigene Seite seit 25.07.2026.
// Vorher steckte sie als 703-Zeilen-Abschnitt in der Auftragsbuch-Ansicht und war damit
// die MEHRHEIT einer Seite, die nach etwas anderem benannt war. Die beiden laufen in
// entgegengesetzte Richtungen: Auftragsbuch = die Box schlaegt DIR vor (Eingang),
// Ideen = DU beauftragst die Box (Ausgang). Seit dem Umbau ist dieser Weg der
// Haupteingang des Systems (Idee -> Konzept -> Repo -> Agentic IDE) und gehoert nach vorn.
//
// v3-Umbau P5 (Befund B-06): Die Datei hatte 1031 Zeilen und 26 useState. Fuenf
// eigenstaendige Komponenten (Wahl, ProjektGruppe, KonzeptPanel, WeiterLeiste,
// ErgebnisPanel) und die Zustandstabellen liegen jetzt daneben — sie hatten ohnehin
// klare Props und keine Verflechtung mit dem Rest.
//
// WAS BEWUSST NOCH HIER STEHT: der Zeilen-Renderer `zeile()`. Er greift auf 16
// Werte des umgebenden Zustands zu (busy, openLog, openKonzept, antworten, logData,
// steuer, archive, answer, …). Ihn herauszuloesen heisst nicht, Code zu verschieben,
// sondern die Zustands-Zugehoerigkeit neu zu entwerfen — eine Props-Liste mit 16
// Eintraegen waere nur eine andere Schreibweise desselben Knotens. Das ist eine eigene
// Runde wert und keine Beifaenger-Aenderung.
function IdeenSection() {
const { data, isLoading } = useIdeen()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [text, setText] = useState("")
// GENAU EINE Auswahl aus drei, in zwei Bereichen (26.07.2026):
// denken → du baust selbst, aber erst ein Konzept. Auf „gefällt mir" wird daraus ein Repo.
// repo → du baust selbst, Konzept + Gitea-Repo sofort.
// box → die Box baut, Ergebnis kommt als Patch-Vorschlag.
//
// Vorher (25.07.) waren das zwei angeblich unabhängige Dimensionen: „wer baut" plus ein
// Haken „nur denken". Das war FALSCH modelliert — `weiterfuehren` macht aus einer
// durchdachten Idee genau die IDE-Projekt-Karte, die `repo` direkt anlegt. Es sind also
// zwei Stufen EINES Weges, nicht zwei Achsen. Und die Kombination box+denken schob einen
// still in die IDE-Welt (submit setzte welt:"ide"), ohne das irgendwo zu sagen.
const [modus, setModus] = useState<"denken" | "repo" | "box">("denken")
const [tiefe, setTiefe] = useState<"schlank" | "gruendlich">("schlank")
// Zugeklappt statt Assistent (26.07.2026): der Entscheidungsbaum EXISTIERT — Wer baut das?,
// und nur im IDE-Ast: wie startest du? — aber du läufst ihn nur, wenn die Einordnung
// danebenlag. Im Normalfall steht da eine Zeile und du drückst Enter.
const [offen, setOffen] = useState(false)
// Hat die Box den Text wirklich gelesen? Steuert nur das Etikett „gelesen" in der
// zugeklappten Zeile. Ohne Einordnung (Text zu kurz, Aufruf gescheitert) steht dort die
// Voreinstellung — und die darf sich nicht als Verständnis ausgeben.
const [vorgeschlagen, setVorgeschlagen] = useState(false)
const isIde = modus !== "box" // denken UND repo laufen über das projektstart-Profil
const isProjekt = modus === "repo" // nur DANN sind Tiefe und Ziel jetzt schon nötig
// Ziel-Plattform des Projekts (nur IDE-Welt): läuft es als LXC-Dienst oder in Docker?
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
const [sending, setSending] = useState(false)
const [busy, setBusy] = useState<Record<string, boolean>>({})
// v3-Umbau P2 (Befund B-05): Die aufgeklappte Karte lag in useState. Wer waehrend
// eines laufenden Workers neu lud — und genau dann schaut man hin — verlor seinen
// Platz in der Liste. Jetzt steht sie in der URL (/ideen?offen=<id>) und ist teilbar.
const navigate = useNavigate()
const suche = useSearch({ from: "/ideen" })
const openLog = suche.offen ?? null
const setOpenLog = (id: string | null) =>
navigate({ to: "/ideen", search: (alt) => ({ ...alt, offen: id ?? undefined }), replace: true })
const [openKonzept, setOpenKonzept] = useState<string | null>(null)
const [antworten, setAntworten] = useState<Record<string, string>>({})
const items = data?.items ?? []
const reload = () => qc.invalidateQueries({ queryKey: qk.ideen })
// Die Box liest mit und wählt vor. Gemessen ~500 ms auf dem dauerwarmen Hirn, deshalb
// reicht eine kurze Tipp-Pause. Bewusst ein VORSCHLAG: er setzt nur die Auswahl, die du
// ohnehin siehst und mit einem Klick überschreibst. Scheitert er, passiert schlicht nichts
// — kein Ladebalken, keine Fehlermeldung, das Tippen wird nie blockiert.
useEffect(() => {
const t = text.trim()
if (t.length < 12) { setVorgeschlagen(false); return }
let verworfen = false
const timer = setTimeout(async () => {
try {
const r = await api<{ ok: boolean; wer?: "box" | "ide"; tiefe?: "schlank" | "gruendlich"; nur_denken?: boolean }>(
"/api/ideen/einordnen", { method: "POST", body: JSON.stringify({ text: t }) })
if (verworfen || !r?.ok) return
// Die drei Karten aus der Einordnung ableiten: eine Frage („lohnt sich…?") wird zum
// Durchdenken, ein eigenständiges Programm zum Repo, alles am Heimlabor zur Box.
setModus(r.nur_denken ? "denken" : r.wer === "ide" ? "repo" : "box")
if (r.tiefe) setTiefe(r.tiefe)
setVorgeschlagen(true)
} catch { /* stumm — die Voreinstellung bleibt einfach stehen */ }
}, 700)
return () => { verworfen = true; clearTimeout(timer) }
}, [text])
// Polling-Log nur aktivieren, wenn Idee triage/running und geöffnet
const logEnabled = openLog !== null && (data?.items.find((i) => i.id === openLog)?.status === "triage" || data?.items.find((i) => i.id === openLog)?.status === "running")
const logData = useIdeenLog(openLog ?? "", logEnabled)
async function submit() {
const t = text.trim()
if (t.length < 3 || sending) return
setSending(true)
try {
// Lange Eingaben: Kurzfassung als Titel, alles als Notiz — der Specifier der Box
// macht daraus ohnehin einen sauberen Auftrag.
const titel = t.length > 160 ? t.slice(0, 157) + "…" : t
await api("/api/ideen", { method: "POST", body: JSON.stringify({
titel, notiz: t.length > 160 ? t : "",
// Abbildung auf die unveränderte API. Tiefe und Ziel gehen NUR beim Repo-Weg mit —
// beim Durchdenken fragt die Weiter-Leiste sie später beim „gefällt mir", und dann
// weiss man mehr als jetzt.
welt: isIde ? "ide" : "box",
tiefe: isProjekt ? (tiefe === "gruendlich" ? "gruendlich" : "simpel") : "",
ziel: isProjekt ? ziel : "",
art: modus === "denken" ? "idee" : "projekt",
}) })
quoteZaehlen("gesendet")
setText("")
setVorgeschlagen(false)
setOffen(false)
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setSending(false)
}
}
// Sammel-Aufräumen: fertige Karten wegräumen, ohne 18-mal auf das X zu klicken.
// Fragt vorher nach — und sagt dabei ehrlich, dass nichts gelöscht wird, sondern
// ins Kanban-Archiv wandert. Das Backend fasst ausschliesslich `done` an.
function archiveAlle(projekt: string, anzahl: number, was: string) {
showConfirm(
`${anzahl} fertige Karte${anzahl === 1 ? "" : "n"} wegräumen?`,
`${was} verschwindet aus der Liste. Gelöscht wird nichts — die Karten wandern ins ` +
`Archiv der Queue und bleiben dort lesbar. Laufende, hängende und wartende Karten ` +
`bleiben unberührt.`,
async () => {
const key = "arch:" + (projekt || "*")
setBusy((b) => ({ ...b, [key]: true }))
try {
const r = await api<{ archiviert: number; fehlgeschlagen: { titel: string }[] }>(
"/api/ideen/archivieren-alle", { method: "POST", body: JSON.stringify({ projekt }) })
if (r.fehlgeschlagen?.length) {
showAlert("Teilweise erledigt",
`${r.archiviert} weggeräumt, ${r.fehlgeschlagen.length} nicht: ` +
r.fehlgeschlagen.map((f) => f.titel).join(", "))
}
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [key]: false }))
}
},
)
}
async function archive(id: string) {
setBusy((b) => ({ ...b, [id]: true }))
try {
await api("/api/ideen/archivieren", { method: "POST", body: JSON.stringify({ id }) })
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [id]: false }))
}
}
// Karten-Steuerung (Stopp / Neuer Versuch / Prio) — ein Helfer für alle drei Endpunkte.
async function steuer(prefix: string, id: string, pfad: string, body: object) {
setBusy((b) => ({ ...b, [prefix + id]: true }))
try {
await api(pfad, { method: "POST", body: JSON.stringify(body) })
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [prefix + id]: false }))
}
}
// Hängende Aufgabe beantworten: die Antwort geht als Kommentar an die Karte, der Worker
// macht damit weiter (Ausweg aus der Sackgasse „fragt, aber niemand kann antworten").
async function answer(id: string) {
const t = (antworten[id] || "").trim()
if (t.length < 1 || busy["ans:" + id]) return
setBusy((b) => ({ ...b, ["ans:" + id]: true }))
try {
await api("/api/ideen/antwort", { method: "POST", body: JSON.stringify({ id, antwort: t }) })
setAntworten((a) => ({ ...a, [id]: "" }))
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, ["ans:" + id]: false }))
}
}
// EINE Zeilen-Darstellung für alle Sichten (Projekt-Gruppen + Einzelkarten) — damit
// Antwortfeld, Live-Log und Ergebnis-Panel überall identisch funktionieren.
const zeile = (it: IdeenItem) => {
const ui = IDEEN_STATE[it.status] ?? { label: it.status, cls: "text-muted-foreground border-border/60 bg-background/30" }
const isOpen = openLog === it.id
// Aufklappbar: laufende Karten zeigen das Live-Log, FERTIGE das Ergebnis
// (User-Wunsch 15.07.: „Dinge werden getestet, ich sehe das Ergebnis nicht").
const canOpen = it.status === "triage" || it.status === "running" || it.status === "done"
const blocked = it.status === "blocked"
const answering = !!busy["ans:" + it.id]
// Hänger-Verdacht: läuft, aber seit >10 min kein Lebenszeichen (Heartbeat) — der
// Prozess kann trotzdem „leben" (wartet z. B. auf einen selbst gestarteten Server).
const haengt = it.status === "running" && (it.puls_alter ?? 0) > 600
const wartend = it.status === "todo" || it.status === "ready" || it.status === "scheduled"
return (
<li key={it.id} className={cn("flex flex-col gap-2 rounded-xl border bg-background/30 px-3 py-2", blocked ? "border-red-500/30" : haengt ? "border-amber-500/40" : "border-border/40")}>
<div className="flex items-center gap-2">
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
{ui.label}
</span>
{haengt && (
<span className="flex shrink-0 items-center gap-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
title="Der Worker lebt, gibt aber kein Lebenszeichen mehr — vermutlich wartet er auf etwas, das nie kommt. „Neuer Versuch“ startet die Karte frisch.">
<AlertTriangle className="h-3 w-3" /> {Math.round((it.puls_alter ?? 0) / 60)} min ohne Regung
</span>
)}
{(it.prio ?? 0) !== 0 && (
<span className="shrink-0 rounded bg-background/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
title="Priorität — höher kommt zuerst dran (bei gleicher Prio: Erstellzeit)">P{it.prio}</span>
)}
{(() => {
const a = ART_UI[it.art || (it.welt === "ide" ? "projekt" : "box")]
if (!a) return null
const Icon = a.icon
return (
<span className={cn("flex shrink-0 items-center gap-1 rounded-lg border px-2 py-0.5 text-[10px] font-bold", a.cls)} title={a.title}>
<Icon className="h-3 w-3" /> {a.label}
</span>
)
})()}
{/* Tooltip = der volle TITEL (der ist abgeschnitten), nicht der Auftragstext —
der beginnt mit Vorspann-Textbausteinen und half beim Lesen nie. */}
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.titel}>{it.titel}</span>
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
{wartend && (
<span className="flex shrink-0 items-center">
<button
disabled={!!busy["pr:" + it.id]}
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "hoch" })}
title="Vorziehen (Prio +1)"
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
disabled={!!busy["pr:" + it.id]}
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "runter" })}
title="Zurückstellen (Prio −1)"
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
<ArrowDown className="h-3.5 w-3.5" />
</button>
</span>
)}
{(it.status === "running" || blocked) && (
<button
disabled={!!busy["rt:" + it.id]}
onClick={() =>
showConfirm("Neuer Versuch?",
it.status === "running"
? `Der laufende Worker von '${it.titel.slice(0, 60)}' wird beendet (samt gestarteter Test-Prozesse) und die Karte frisch gestartet. Bisheriger Zwischenstand im Workspace bleibt liegen.`
: `Die Karte '${it.titel.slice(0, 60)}' wird entsperrt und läuft weiter, sobald ein Platz frei ist.`,
() => steuer("rt:", it.id, "/api/ideen/retry", { id: it.id }))}
title="Neuer Versuch — Karte frisch starten"
className={cn("shrink-0 rounded-md p-1 transition-colors cursor-pointer disabled:opacity-50",
haengt ? "text-amber-300 hover:text-amber-200" : "text-muted-foreground/60 hover:text-foreground")}>
{busy["rt:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
</button>
)}
{(it.status === "running" || wartend || it.status === "triage") && (
<button
disabled={!!busy["st:" + it.id]}
onClick={() =>
showConfirm("Karte stoppen?",
`'${it.titel.slice(0, 60)}' wird angehalten${it.status === "running" ? " und der laufende Worker beendet" : ""}. Sie bleibt als gestoppt im Board und kann jederzeit per „Neuer Versuch“ weiterlaufen.`,
() => steuer("st:", it.id, "/api/ideen/stopp", { id: it.id }))}
title="Stoppen — Karte anhalten"
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-red-300 cursor-pointer disabled:opacity-50">
{busy["st:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
</button>
)}
{(it.status === "done" || blocked) && (
<button
disabled={!!busy[it.id]}
onClick={() => archive(it.id)}
title={blocked ? "Aufgeben und aus der Liste räumen (wandert ins Kanban-Archiv)" : "Aus der Liste räumen (wandert ins Kanban-Archiv)"}
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
</button>
)}
{it.welt === "ide" && (it.status === "running" || it.status === "done") && (
<button
onClick={() => setOpenKonzept(openKonzept === it.id ? null : it.id)}
title="Konzept ansehen (das ausgearbeitete KONZEPT.md)"
className={cn("flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-bold transition-colors cursor-pointer",
openKonzept === it.id ? "text-violet-300" : "text-violet-300/70 hover:text-violet-200")}>
<FileText className="h-3.5 w-3.5" /> Konzept
</button>
)}
{canOpen && (
<button
onClick={() => setOpenLog(isOpen ? null : it.id)}
title={it.status === "done" ? "Ergebnis anzeigen" : "Worker-Log anzeigen"}
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer">
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
</div>
{openKonzept === it.id && (
<KonzeptPanel
id={it.id}
istIdee={it.art === "idee" && it.status === "done"}
onWeiter={reload}
/>
)}
{it.status === "running" && it.notiz && (
<p className="-mt-1 ml-1 text-[11px] italic leading-relaxed text-sky-200/80" title="Jüngste Zwischenmeldung des Workers (Heartbeat)">
„{it.notiz}“
</p>
)}
{(it.status === "todo" || it.status === "scheduled" || it.status === "ready") && (it.wartet_auf?.length ?? 0) > 0 && (
<p className="-mt-1 ml-1 text-[10px] text-muted-foreground/70">
→ wartet auf: {it.wartet_auf!.join(" · ")}
</p>
)}
{blocked && (
<div className="space-y-2 border-t border-red-500/20 pt-2">
<p className="flex items-start gap-1.5 text-[11px] leading-relaxed text-red-200/90">
<HelpCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-300" />
<span>
<b>{FRAGE_HINT[it.frage_kind || ""] ?? "Die Aufgabe hängt und wartet auf dich:"}</b>{" "}
{it.frage
? <span className="text-foreground/90">{it.frage}</span>
: <span className="text-muted-foreground italic">kein genauer Grund hinterlegt — antworte einfach mit einer Entscheidung, dann läuft sie weiter.</span>}
</span>
</p>
<div className="flex gap-2">
<input
value={antworten[it.id] || ""}
onChange={(e) => setAntworten((a) => ({ ...a, [it.id]: e.target.value }))}
onKeyDown={(e) => { if (e.key === "Enter") answer(it.id) }}
placeholder="Deine Antwort — die Box macht damit weiter"
maxLength={2000}
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-red-400/50 focus:outline-none"
/>
<button
onClick={() => answer(it.id)}
disabled={answering || (antworten[it.id] || "").trim().length < 1}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
{answering ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} Antworten &amp; weiter
</button>
</div>
</div>
)}
{isOpen && canOpen && it.status !== "done" && (
<div className="ml-2 -mt-1">
<div className="rounded-lg border border-border/50 bg-background/50 p-2">
{logEnabled && logData.isLoading ? (
<p className="text-[10px] text-muted-foreground animate-pulse">Log wird geladen …</p>
) : logData.error ? (
<p className="text-[10px] text-red-400">Log nicht ladbar</p>
) : logData.data?.available && logData.data.lines.length > 0 ? (
<pre className="max-h-64 overflow-auto font-mono text-[10px] leading-relaxed text-foreground/80 whitespace-pre-wrap">
{logData.data.lines.join("\n")}
</pre>
) : (
<p className="text-[10px] text-muted-foreground">Kein Log verfügbar</p>
)}
</div>
</div>
)}
{isOpen && it.status === "done" && <ErgebnisPanel id={it.id} />}
</li>
)
}
// Windows-Dev / Queue nicht verfügbar → Sektion still weglassen (wie der Rest der Seite).
if (data && !data.available) return null
return (
<section className="space-y-3">
{/* Kopfzeile sagt in einem Blick, was DICH betrifft — vorher stand hier nur eine
Gesamtzahl „offen", die nichts darüber verriet, ob etwas auf dich wartet.
Rechts daneben das Sammel-Aufräumen, das nur erscheint, wenn es etwas zu räumen gibt. */}
<div className="flex items-center justify-between gap-3">
<SectionLabel icon={Lightbulb}>
{(() => {
if (!data) return "Ideen-Queue"
const n = (s: string[]) => items.filter((i) => s.includes(i.status)).length
const teile = [
n(["blocked"]) > 0 ? `${n(["blocked"])} braucht dich` : "",
n(["running", "triage", "review"]) > 0 ? `${n(["running", "triage", "review"])} in Arbeit` : "",
n(["todo", "scheduled", "ready"]) > 0 ? `${n(["todo", "scheduled", "ready"])} wartet` : "",
n(["done"]) > 0 ? `${n(["done"])} fertig` : "",
].filter(Boolean)
// Nur der Zustand, nicht nochmal der Seitenname: „Ideen" steht bereits als
// Überschrift darüber und ein drittes Mal in der Kopfzeile der App.
return teile.length ? teile.join(" · ") : "noch nichts in der Queue"
})()}
</SectionLabel>
{(() => {
const fertig = items.filter((i) => i.status === "done").length
if (fertig === 0) return null
return (
<button
disabled={!!busy["arch:*"]}
onClick={() => archiveAlle("", fertig, `Alle ${fertig} fertigen Karten`)}
title="Räumt ALLE fertigen Karten weg — auch die in abgeschlossenen Projekt-Ketten. Laufende, hängende und wartende bleiben unberührt."
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1 text-[11px] font-semibold text-muted-foreground transition-all hover:border-primary/40 hover:text-foreground disabled:opacity-50 cursor-pointer">
{busy["arch:*"] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
{fertig} fertige wegräumen
</button>
)
})()}
</div>
<div className="mc-card p-4 space-y-4">
{/* Schreiben zuerst. Alles darunter ist eine Entscheidung ZU dem, was hier steht —
nicht davor. Der Knopf heisst immer gleich; was passiert, steht bei den Karten. */}
<div className="flex gap-2">
<input
value={text}
onChange={(e) => { setText(e.target.value); setVorgeschlagen(false) }}
onKeyDown={(e) => { if (e.key === "Enter") submit() }}
placeholder="Was soll entstehen?"
className="h-10 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/50 px-3 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/50 focus:outline-none"
/>
<button
onClick={submit}
disabled={sending || text.trim().length < 3}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-lg border border-primary/40 bg-primary/10 px-4 text-xs font-semibold text-primary transition-all hover:bg-primary/20 disabled:opacity-40 cursor-pointer">
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Los
</button>
</div>
{/* ZUGEKLAPPT STATT ASSISTENT. Der Entscheidungsbaum ist derselbe, den ein Wizard
abfragen wuerde — Wer baut das?, und NUR im IDE-Ast: wie startest du? Nur laeufst
du ihn erst, wenn die Einordnung danebenlag. Ein Assistent wuerde dich fragen, was
die Box in ~500 ms schon weiss; das hier fragt nur im Zweifelsfall.
IMMER sichtbar (26.07., User-Befund): vorher hing der ganze Block an
`text.length >= 3` — bei leerem Feld gab es also keinerlei Hinweis, dass es
ueberhaupt eine Wahl gibt. Ein Mechanismus, den man nicht sieht, existiert fuer
den Nutzer nicht. Ohne Text steht dort die Voreinstellung, klar als solche
benannt, und laesst sich schon vorher aendern. */}
{(
offen ? (
<div className="space-y-3 rounded-xl border border-border/40 bg-background/20 p-3">
<div>
<p className="mb-1.5 text-[11px] text-muted-foreground/70">Wer baut das?</p>
<div className="grid gap-2 sm:grid-cols-2">
<Wahl aktiv={modus !== "box"}
onWahl={() => setModus(modus === "box" ? "denken" : modus)}
k={{ id: "ide", icon: Code2, titel: "Ich, in der Agentic IDE",
text: "Du bekommst Konzept und Repo — den Code schreibst du selbst." }} />
<Wahl aktiv={modus === "box"}
onWahl={() => setModus("box")}
k={{ id: "box", icon: Hammer, titel: "Die Box, allein",
text: "Für dein Heimlabor. Das Ergebnis kommt als Branch nach Gitea — mergen tust du." }} />
</div>
</div>
{/* Die zweite Frage gibt es NUR im IDE-Ast — eingerückt, damit die Verschachtelung
sichtbar ist. Bei „die Box" folgt keine Rückfrage: dann geht es direkt los. */}
{modus !== "box" && (
<div className="border-l-2 border-primary/40 pl-3">
<p className="mb-1.5 text-[11px] text-muted-foreground/70">Und wie startest du?</p>
<div className="grid gap-2 sm:grid-cols-2">
<Wahl aktiv={modus === "denken"} onWahl={() => setModus("denken")}
k={{ id: "denken", icon: Lightbulb, titel: "Erst durchdenken",
text: "Konzept zuerst — auf dein Ja wird ein Repo daraus." }} />
<Wahl aktiv={modus === "repo"} onWahl={() => setModus("repo")}
k={{ id: "repo", icon: GitFork, titel: "Direkt ein Repo",
text: "Konzept und Gitea-Repo in einem Zug." }} />
</div>
{modus === "repo" ? (
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="text-[11px] text-muted-foreground/70">Wie gründlich?</span>
<div className="flex rounded-lg border border-border/60 p-0.5">
{([["schlank", "schlank"], ["gruendlich", "gründlich"]] as const).map(([id, label]) => (
<button key={id} onClick={() => setTiefe(id)}
className={cn("rounded px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
tiefe === id ? "bg-primary/15 text-primary" : "text-muted-foreground/60 hover:text-foreground")}>
{label}
</button>
))}
</div>
<span className="ml-auto flex items-center gap-1.5">
<span className="text-[11px] text-muted-foreground/70">Läuft als</span>
<div className="flex rounded-lg border border-border/40 p-0.5">
{([["lxc", "LXC"], ["docker", "Docker"]] as const).map(([id, label]) => (
<button key={id} onClick={() => setZiel(id)}
title={id === "lxc"
? "Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker). Wird vom Backup-Server mitgesichert."
: "Natives Docker: Dockerfile + compose, portabel und pipeline-tauglich."}
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
ziel === id ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
{label}
</button>
))}
</div>
</span>
</div>
) : (
<p className="mt-2 flex items-start gap-1.5 text-[10px] leading-relaxed text-muted-foreground/60">
<HelpCircle className="mt-px h-3 w-3 shrink-0" />
Wie gründlich und wo es läuft, fragt die Box erst beim „gefällt mir" — dann weißt du mehr.
</p>
)}
</div>
)}
{/* Beweisbar statt behauptet: die Quote, an der dieser ganze Entwurf haengt. */}
{(() => {
const q = quoteLesen()
if (q.gesendet < 3) return null
return (
<p className="border-t border-border/30 pt-2 text-[10px] text-muted-foreground/50">
Du hast {q.geaendert} von {q.gesendet} Vorschlägen geändert. Wird das die Regel,
bauen wir hier einen richtigen Schritt-für-Schritt-Assistenten.
</p>
)
})()}
</div>
) : (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border/40 bg-background/20 px-3 py-2.5 text-xs">
{modus === "box" ? <Hammer className="h-4 w-4 shrink-0 text-primary" />
: modus === "repo" ? <GitFork className="h-4 w-4 shrink-0 text-primary" />
: <Lightbulb className="h-4 w-4 shrink-0 text-primary" />}
{/* Ohne Einordnung ist der Satz eine EINSTELLUNG, keine Aussage über deinen
Text — das muss dranstehen, sonst behauptet die Zeile ein Verständnis,
das es (noch) nicht gibt. */}
{!vorgeschlagen && (
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground/50">
Voreingestellt
</span>
)}
<span className="text-muted-foreground">
{modus === "box" ? "Ich baue das selbst und lege dir das Ergebnis als Vorschlag hin."
: modus === "repo" ? "Du baust selbst — ich lege dir Konzept und Repo an."
: "Du baust selbst — ich arbeite erst ein Konzept aus."}
</span>
{vorgeschlagen && (
<span className="shrink-0 rounded border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold text-primary">
gelesen
</span>
)}
<button
onClick={() => { setOffen(true); quoteZaehlen("geaendert") }}
className="ml-auto shrink-0 text-[11px] text-muted-foreground/60 underline underline-offset-2 transition-colors hover:text-foreground cursor-pointer">
anders
</button>
</div>
)
)}
{isLoading && (
<p className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" /> Queue wird geladen …</p>
)}
{data?.fehler && (
<p className="text-xs text-amber-300">Queue gerade nicht lesbar — neuer Versuch läuft.</p>
)}
{items.length > 0 && (
<div className="space-y-3">
{(data?.projekte ?? []).map((p) => (
<ProjektGruppe key={p.key} projekt={p} karten={items.filter((i) => i.projekt === p.key)}
zeile={zeile} onArchivAlle={archiveAlle} raeumt={!!busy["arch:" + p.key]} />
))}
{(() => {
// Einzelkarten (keine Kette). Bis 25.07. eine flache Erstellzeit-Liste — bei
// 19 Karten, davon 16 fertig, gingen die drei relevanten darin unter. Jetzt
// dieselbe Ordnung, die ProjektGruppe für Ketten längst benutzt: aktiv zuerst
// nach AKTIV_RANG, Wartende darunter, Fertige eingeklappt.
const keys = new Set((data?.projekte ?? []).map((p) => p.key))
const einzeln = items.filter((i) => !i.projekt || !keys.has(i.projekt))
if (einzeln.length === 0) return null
const aktiv = einzeln
.filter((i) => AKTIV_RANG[i.status] !== undefined)
.sort((a, b) => (AKTIV_RANG[a.status] - AKTIV_RANG[b.status]) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
const wartend = einzeln.filter((i) => ["todo", "scheduled"].includes(i.status))
const fertig = einzeln.filter((i) => i.status === "done")
return (
<div className="space-y-2">
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
{wartend.length > 0 && (
<details className="group">
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] text-muted-foreground/70 hover:text-foreground">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
<Clock className="h-3 w-3" /> Wartet ({wartend.length})
</summary>
<ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>
</details>
)}
{fertig.length > 0 && (
<details className="group">
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] text-muted-foreground/70 hover:text-foreground">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
<Check className="h-3 w-3 text-emerald-400/70" /> Fertig ({fertig.length})
</summary>
<ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>
</details>
)}
</div>
)
})()}
</div>
)}
{data?.available && !isLoading && items.length === 0 && (
// Leerer Zustand als Einladung statt als Feststellung: genau jetzt möchte man
// wissen, was man überhaupt hineinschreiben kann. Ein Klick füllt das Feld —
// die Box ordnet den Beispieltext dann selbst ein.
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground/70">Noch nichts hier. Zum Beispiel:</p>
{[
"Ein kleines Werkzeug, das meine Fotos nach Aufnahmedatum in Ordner sortiert",
"Rippy soll die Untertitel-Spur automatisch auswählen statt zu fragen",
"Lohnt sich ein Dashboard für die Stromzähler, oder reicht Home Assistant?",
].map((b) => (
<button key={b} onClick={() => setText(b)}
className="flex w-full items-center gap-2 rounded-lg border border-border/40 bg-background/20 px-3 py-2 text-left text-xs text-muted-foreground transition-all hover:border-primary/40 hover:text-foreground cursor-pointer">
<Lightbulb className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
{b}
</button>
))}
</div>
)}
</div>
{dialogElement}
</section>
)
}
// Eine der drei Auswahl-Karten. Der Text steht AN der Option, nicht in einer Legende
// darunter — man liest nur, was zu dem gehört, was man gerade anschaut.
export function IdeenView() {
return (
<div className="space-y-6">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Ideen
</h1>
<p className="text-sm text-muted-foreground">
Schreib hin, was entstehen soll. Die Box liest mit und schlägt vor, wie es weitergeht —
ändern kannst du das immer.
</p>
</div>
<IdeenSection />
</div>
)
}
@@ -1,73 +0,0 @@
// Konzept-Panel einer Idee: zeigt den Entwurf, den Lucy geschrieben hat, und bietet
// den naechsten Schritt an (nachschaerfen oder als Projekt bauen).
//
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
import { useEffect, useState } from "react"
import { FileText, GitFork, RefreshCw } from "lucide-react"
import { api, type IdeenKonzept } from "@/lib/api"
import { Markdown } from "@/lib/markdown"
import { WeiterLeiste } from "./WeiterLeiste"
export function KonzeptPanel({ id, istIdee, onWeiter }: {
id: string
istIdee?: boolean
onWeiter?: () => void
}) {
const [data, setData] = useState<IdeenKonzept | null>(null)
const [fehler, setFehler] = useState(false)
const [versuch, setVersuch] = useState(0)
useEffect(() => {
let weg = false
setData(null); setFehler(false)
api<IdeenKonzept>(`/api/ideen/${id}/konzept`)
.then((r) => { if (!weg) setData(r) })
.catch(() => { if (!weg) setFehler(true) })
return () => { weg = true }
}, [id, versuch])
return (
<div className="ml-2 -mt-1">
<div className="rounded-lg border border-violet-500/25 bg-violet-500/[0.04] p-3">
<div className="mb-2 flex items-center gap-2">
<p className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-violet-300">
<FileText className="h-3 w-3" /> Konzept
</p>
{data?.repo && (
<a href={`https://git.tobisniceshomelab.ddnsfree.com/${data.repo}`}
className="flex items-center gap-1 text-[10px] text-violet-300/70 hover:text-violet-200">
<GitFork className="h-3 w-3" /> {data.repo}
</a>
)}
{!data?.repo && data?.quelle === "datei" && (
<span className="truncate font-mono text-[10px] text-muted-foreground/70"
title="Der Worker hat kein Repo angelegt — das Konzept liegt als Datei auf der Box.">
{data.pfad}
</span>
)}
<button onClick={() => setVersuch((v) => v + 1)} title="Neu laden"
className="ml-auto rounded p-0.5 text-muted-foreground/50 hover:text-foreground cursor-pointer">
<RefreshCw className="h-3 w-3" />
</button>
</div>
{fehler ? (
<p className="text-[11px] text-red-400">Konzept nicht ladbar — später nochmal versuchen.</p>
) : data === null ? (
<p className="text-[10px] text-muted-foreground animate-pulse">Konzept wird geladen …</p>
) : data.konzept ? (
<div className="max-h-[28rem] overflow-y-auto pr-1">
<Markdown text={data.konzept} />
</div>
) : (
<p className="text-[11px] text-muted-foreground italic">
{data.error || "Noch kein Konzept — das Projekt wird gerade vorbereitet."}
</p>
)}
{istIdee && data?.konzept && <WeiterLeiste id={id} onFertig={onWeiter} />}
</div>
</div>
)
}
// Was nach dem Lesen passieren kann. Zwei Wege, bewusst nebeneinander statt hintereinander:
// nachschärfen (ein Punkt passt nicht — häufiger Fall, darum oben und offen) oder annehmen.
@@ -1,86 +0,0 @@
// Karten-Familie (verkettete Karten = ein Projekt) als Block: Fortschritt oben, dann die
// AKTIVEN Karten in Ausfuehrungs-Reihenfolge, Wartende und Fertige zusammengeklappt.
// Ersetzt fuer Ketten die flache Erstellzeit-Liste (User: „Reihenfolge komplett durcheinander").
//
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
import { useState } from "react"
import { ChevronDown, ChevronRight, Layers, Loader2, X } from "lucide-react"
import type { IdeenItem, IdeenProjekt } from "@/lib/api"
import { AKTIV_RANG } from "./zustaende"
export function ProjektGruppe({ projekt, karten, zeile, onArchivAlle, raeumt }: {
projekt: IdeenProjekt
karten: IdeenItem[]
zeile: (it: IdeenItem) => React.ReactNode
onArchivAlle?: (projekt: string, anzahl: number, was: string) => void
raeumt?: boolean
}) {
const [zeigWartende, setZeigWartende] = useState(false)
const [zeigFertige, setZeigFertige] = useState(false)
const aktiv = karten
.filter((i) => AKTIV_RANG[i.status] !== undefined)
.sort((a, b) => (AKTIV_RANG[a.status] - AKTIV_RANG[b.status]) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
const wartend = karten
.filter((i) => i.status === "todo" || i.status === "scheduled")
.sort((a, b) => ((a.wartet_auf?.length ? 1 : 0) - (b.wartet_auf?.length ? 1 : 0)) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
const fertig = karten
.filter((i) => i.status === "done")
.sort((a, b) => (b.fertig ?? 0) - (a.fertig ?? 0))
const anteil = projekt.gesamt > 0 ? Math.round((projekt.fertig / projekt.gesamt) * 100) : 0
return (
<div className="space-y-2 rounded-xl border border-border/50 bg-background/20 p-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 shrink-0 text-primary/70" />
<p className="min-w-0 flex-1 truncate text-xs font-semibold text-foreground" title={projekt.titel}>{projekt.titel}</p>
<span className="shrink-0 text-[10px] text-muted-foreground">{projekt.fertig} von {projekt.gesamt} fertig</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-background/60" title={`${anteil}%`}>
<div className="h-1 rounded-full bg-primary/70 transition-all" style={{ width: `${anteil}%` }} />
</div>
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
{wartend.length > 0 && (
<div>
<button
onClick={() => setZeigWartende((v) => !v)}
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
{zeigWartende ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{wartend.length} Karte{wartend.length === 1 ? "" : "n"} in der Kette — laufen automatisch der Reihe nach
</button>
{zeigWartende && <ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>}
</div>
)}
{fertig.length > 0 && (
<div>
<div className="flex items-center gap-2">
<button
onClick={() => setZeigFertige((v) => !v)}
className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-300/70 hover:text-emerald-300 transition-colors cursor-pointer">
{zeigFertige ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{fertig.length} fertig
</button>
{/* Ganze abgeschlossene Kette in einem Zug wegräumen — bei fünf fertigen
Projekten waren das sonst ~18 Einzelklicks auf das X. */}
{onArchivAlle && (
<button
disabled={raeumt}
onClick={() => onArchivAlle(projekt.key, fertig.length, `„${projekt.titel}"`)}
title="Räumt die fertigen Karten dieser Kette weg (Archiv, nichts wird gelöscht)."
className="flex items-center gap-1 text-[11px] text-muted-foreground/60 transition-colors hover:text-foreground disabled:opacity-50 cursor-pointer">
{raeumt ? <Loader2 className="h-3 w-3 animate-spin" /> : <X className="h-3 w-3" />}
wegräumen
</button>
)}
</div>
{zeigFertige && <ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>}
</div>
)}
</div>
)
}
// Konzept einer IDE-Karte: holt es aus dem Gitea-Repo — oder, wenn der Worker keins
// angelegt hat, aus seiner Datei in ~/konzepte (Bug-Fund 21.07.: dann stand hier vorher
// „noch kein Repo hinterlegt", obwohl das Konzept längst fertig war).
// Bei einer fertig durchdachten IDEE hängt unten der Weg nach vorn: „Gefällt mir".
-27
View File
@@ -1,27 +0,0 @@
// Eine der drei Auswahlkacheln im Ideen-Verfasser (Wer baut das? / Wie startest du?).
//
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
import { Hammer } from "lucide-react"
import { cn } from "@/lib/utils"
export function Wahl({ k, aktiv, onWahl }: {
k: { id: string; icon: typeof Hammer; titel: string; text: string }
aktiv: boolean
onWahl: () => void
}) {
return (
<button
onClick={onWahl}
className={cn("rounded-xl border p-3 text-left transition-all cursor-pointer",
aktiv ? "border-primary/60 bg-primary/5" : "border-border/50 hover:border-border")}>
<div className="flex items-center gap-2">
<k.icon className={cn("h-4 w-4 shrink-0", aktiv ? "text-primary" : "text-muted-foreground")} />
<span className="text-xs font-semibold text-foreground">{k.titel}</span>
</div>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{k.text}</p>
</button>
)
}
@@ -1,113 +0,0 @@
// Die Leiste unter einem fertigen Konzept: nachschaerfen oder als Projekt weiterfuehren.
//
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
import { useState } from "react"
import { Code2, Loader2, RefreshCw } from "lucide-react"
import { api } from "@/lib/api"
import { cn } from "@/lib/utils"
export function WeiterLeiste({ id, onFertig }: { id: string; onFertig?: () => void }) {
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
const [tiefe, setTiefe] = useState<"simpel" | "gruendlich">("simpel")
const [busy, setBusy] = useState(false)
const [neu, setNeu] = useState<{ id: string; art: "projekt" | "runde" } | null>(null)
const [fehler, setFehler] = useState("")
const [hinweis, setHinweis] = useState("")
const ruf = async (pfad: string, rumpf: object, art: "projekt" | "runde") => {
setBusy(true); setFehler("")
try {
const r = await api<{ id: string }>(pfad, { method: "POST", body: JSON.stringify(rumpf) })
setNeu({ id: r.id, art })
onFertig?.()
} catch (e: any) {
setFehler(String(e?.message || e))
} finally {
setBusy(false)
}
}
if (neu) {
return (
<p className="mt-3 border-t border-violet-500/20 pt-2 text-[11px] text-emerald-300">
{neu.art === "projekt" ? (
<>Projekt-Karte <span className="font-mono">{neu.id}</span> ist angelegt — die Box bereitet
das Repo vor (Konzept übernimmt sie, sie denkt es nicht neu). Danach öffnest du es in der Agentic IDE.</>
) : (
<>Überarbeitung <span className="font-mono">{neu.id}</span> läuft — die Box arbeitet deine
Rückmeldung ein und schreibt die neue Fassung an dieselbe Stelle. Danach hier wieder
„Konzept" öffnen.</>
)}
</p>
)
}
return (
<div className="mt-3 space-y-3 border-t border-violet-500/20 pt-2.5">
<div className="space-y-1.5">
<p className="text-[11px] text-muted-foreground">
<b className="text-sky-300/90">Passt etwas nicht?</b> Schreib hin, was anders werden soll —
die Box arbeitet es ein und lässt den Rest stehen. Beliebig oft wiederholbar.
</p>
<div className="flex gap-2">
<input
value={hinweis}
onChange={(e) => setHinweis(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && hinweis.trim().length >= 3 && !busy) ruf("/api/ideen/nachschaerfen", { id, hinweis }, "runde") }}
placeholder="z. B. „Punkt 3: kein WebSocket, das ist mir zu fragil — nimm Polling“"
maxLength={2000}
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-sky-400/50 focus:outline-none"
/>
<button
onClick={() => ruf("/api/ideen/nachschaerfen", { id, hinweis }, "runde")}
disabled={busy || hinweis.trim().length < 3}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-sky-300 transition-all hover:bg-sky-500/20 cursor-pointer disabled:opacity-50">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
Nachschärfen
</button>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
<b className="text-violet-300/90">Passt es so?</b> Dann macht die Box daraus ein startklares
Repo — Konzept wird übernommen (nicht neu gedacht), dazu Roadmap, AGENTS.md &amp; Co.
Gebaut wird danach von dir in der Agentic IDE.
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="text-[11px] text-muted-foreground/70">Ziel</span>
<div className="flex rounded-lg border border-border/40 p-0.5">
{(["lxc", "docker"] as const).map((z) => (
<button key={z} onClick={() => setZiel(z)}
title={z === "lxc" ? "Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker) — wird automatisch mitgesichert."
: "Natives Docker: Dockerfile + compose, portabel und pipeline-tauglich."}
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
ziel === z ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
{z === "lxc" ? "LXC" : "Docker"}
</button>
))}
</div>
<span className="text-[11px] text-muted-foreground/70">Aufwand</span>
<div className="flex rounded-lg border border-border/40 p-0.5">
{(["simpel", "gruendlich"] as const).map((t) => (
<button key={t} onClick={() => setTiefe(t)}
title={t === "simpel" ? "Konzept ist schon gehärtet — nur kurz auf die Bau-Sicht nachschärfen (empfohlen)."
: "Nochmal volle Kaskade über die Bau-Sicht: mehrere Rollen, Härtetests. Dauert entsprechend."}
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
tiefe === t ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
{t === "simpel" ? "schlank" : "gründlich"}
</button>
))}
</div>
<button
onClick={() => ruf("/api/ideen/weiterfuehren", { id, ziel, tiefe }, "projekt")}
disabled={busy}
className="ml-auto flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-violet-300 transition-all hover:bg-violet-500/20 cursor-pointer disabled:opacity-50">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Code2 className="h-3.5 w-3.5" />}
Gefällt mir — Projekt daraus machen
</button>
</div>
{fehler && <p className="text-[11px] text-red-400">{fehler}</p>}
</div>
)
}
// Ergebnis einer FERTIGEN Queue-Aufgabe: die Abschluss-Zusammenfassung des Workers
// (kanban_complete). Lazy — wird erst beim Aufklappen geladen, ändert sich nie mehr.
-22
View File
@@ -1,22 +0,0 @@
// Kleine Nutzungs-Quote der Ideen-Eingabe (v3-Umbau P5, unveraendert aus IdeenView).
//
// Zaehlt im localStorage mit, wie oft abgeschickt und wie oft die Einordnung korrigiert
// wurde. Reine Bedienstatistik, kein Geheimnis — der Zaun aus P1 (ESLint-Regel gegen
// Geheimnisse im localStorage) greift hier bewusst nicht.
const QUOTE_KEY = "mc_ideen_quote"
export function quoteLesen(): { gesendet: number; geaendert: number } {
try {
const d = JSON.parse(localStorage.getItem(QUOTE_KEY) || "{}")
return { gesendet: Number(d.gesendet) || 0, geaendert: Number(d.geaendert) || 0 }
} catch { return { gesendet: 0, geaendert: 0 } }
}
export function quoteZaehlen(feld: "gesendet" | "geaendert"): void {
try {
const q = quoteLesen()
q[feld] += 1
localStorage.setItem(QUOTE_KEY, JSON.stringify(q))
} catch { /* privater Modus o. ä. — die Zählung ist Beiwerk, nie ein Grund zu scheitern */ }
}
-55
View File
@@ -1,55 +0,0 @@
// Zustands- und Art-Tabellen der Ideen-Queue (v3-Umbau P5).
//
// Herausgeschnitten aus dem 1031-Zeilen-Monolithen views/IdeenView.tsx. Diese Tabellen
// sind reine Daten und werden von Liste, Projektgruppe und Panels gebraucht — sie stehen
// deshalb fuer sich, statt in einer der Komponenten zu wohnen.
import { Code2, Hammer, Lightbulb } from "lucide-react"
export const IDEEN_STATE: Record<string, { label: string; cls: string; spin?: boolean }> = {
triage: { label: "wird ausgearbeitet …", cls: "text-violet-300 border-violet-500/40 bg-violet-500/10", spin: true },
todo: { label: "wartet", cls: "text-muted-foreground border-border/60 bg-background/30" },
scheduled: { label: "geplant", cls: "text-muted-foreground border-border/60 bg-background/30" },
ready: { label: "gleich dran", cls: "text-sky-300/80 border-sky-500/30 bg-sky-500/5" },
running: { label: "in Arbeit …", cls: "text-sky-300 border-sky-500/40 bg-sky-500/10", spin: true },
review: { label: "im Review", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10" },
blocked: { label: "hängt — braucht dich", cls: "text-red-300 border-red-500/40 bg-red-500/10" },
done: { label: "fertig ✓", cls: "text-emerald-300 border-emerald-500/40 bg-emerald-500/10" },
}
// Welche Welt hat die Karte durchlaufen? Vorher stand auf Idee UND IDE-Projekt dasselbe
// „IDE" — man sah der fertigen Karte nicht an, ob sie nur durchdacht oder als Repo
// vorbereitet wurde (User 21.07.). Jetzt drei ehrliche Marken.
export const ART_UI: Record<string, { label: string; cls: string; icon: typeof Hammer; title: string }> = {
box: {
label: "BOX", icon: Hammer,
cls: "border-border/60 bg-background/40 text-muted-foreground",
title: "Box-Welt: die Werkstatt baut das selbst. Das Ergebnis kommt als Patch-Vorschlag zurück — nichts geht ohne deinen Klick live.",
},
idee: {
label: "IDEE", icon: Lightbulb,
cls: "border-sky-500/40 bg-sky-500/10 text-sky-300",
title: "Nur durchdacht: die Box hat die Produkt-Idee mit voller Denk-Kaskade zu einem Konzept ausgearbeitet. Kein Gerüst, kein Bau — du liest es und entscheidest, ob ein Projekt draus wird.",
},
projekt: {
label: "IDE", icon: Code2,
cls: "border-violet-500/40 bg-violet-500/10 text-violet-300",
title: "IDE-Welt: die Box bereitet nur vor (Konzept + Repo + Doku, kein Code). Gebaut wird von dir in der Agentic IDE.",
},
}
// Wie die Frage einer hängenden Karte eingeleitet wird — je nach Block-Grund des Workers.
export const FRAGE_HINT: Record<string, string> = {
needs_input: "Die Box hat eine Frage:",
capability: "Die Box kam hier nicht weiter:",
transient: "Die Aufgabe hakt:",
}
// Wie oft lag die Einordnung daneben? Der ganze zugeklappte Entwurf steht und fällt damit.
// Bewusst im Browser statt im Backend: zwei Zahlen brauchen keine Tabelle, keinen Endpunkt
// und keine Migration — und es geht niemanden ausser dem Nutzer etwas an. Sichtbar wird die
// Quote nur beim Aufklappen, damit sie beweisbar ist statt behauptet.
// Sortier-Rang der AKTIVEN Karten innerhalb einer Projekt-Gruppe. Steht hier bei den
// anderen Zustands-Tabellen, weil ihn Liste UND Projektgruppe brauchen.
export const AKTIV_RANG: Record<string, number> = { blocked: 0, running: 1, review: 2, ready: 3, triage: 4 }
+153 -238
View File
@@ -1,256 +1,171 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource/barlow/400.css";
@import "@fontsource/barlow/500.css";
@import "@fontsource/barlow/600.css";
@import "@fontsource/barlow-condensed/500.css";
@import "@fontsource/barlow-condensed/600.css";
@import "@fontsource/barlow-condensed/700.css";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/500.css";
/* Fonts lokal gebündelt (@fontsource) — die Box-UI braucht kein Internet für Schrift.
Bewusst KEINE Sammel-Importe (`@fontsource/inter/400.css`): die ziehen alle Subsets
(latin-ext, griechisch, kyrillisch) UND je eine WOFF-1-Fassung mit — 112 Dateien,
1,58 MB, davon 57 % WOFF 1, das kein Browser dieser App je abruft. Hier stehen
deshalb die @font-face-Regeln selbst: nur `latin`, nur WOFF 2, nur die Schnitte,
die im Code wirklich vorkommen (geprüft per grep über die font-*-Klassen).
Neuen Schnitt gebraucht? Zeile ergänzen — nicht auf den Sammel-Import zurückfallen. */
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 400;
src: url("@fontsource/inter/files/inter-latin-400-normal.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 500;
src: url("@fontsource/inter/files/inter-latin-500-normal.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 600;
src: url("@fontsource/inter/files/inter-latin-600-normal.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 700;
src: url("@fontsource/inter/files/inter-latin-700-normal.woff2") format("woff2"); }
/*
* Cockpit (Box-Wart-Umbau 09/2026, Richtung A): dunkles Instrumentenbrett.
* Bernstein = Achtung (Hinweise, Hauptknöpfe), Grün = läuft, Cyan = Info/Anzeige,
* Rot = Störung. Farbe erscheint nur, wenn etwas ist — im Normalfall bleibt es ruhig.
* Nur ein dunkles Design (User-Entscheid 23.09.), daher alle Werte direkt auf :root.
*/
/* Space Grotesk trägt nur Überschriften (font-space): 400 als Grundschnitt, 600/700 benutzt. */
@font-face { font-family: "Space Grotesk"; font-style: normal; font-display: swap; font-weight: 400;
src: url("@fontsource/space-grotesk/files/space-grotesk-latin-400-normal.woff2") format("woff2"); }
@font-face { font-family: "Space Grotesk"; font-style: normal; font-display: swap; font-weight: 600;
src: url("@fontsource/space-grotesk/files/space-grotesk-latin-600-normal.woff2") format("woff2"); }
@font-face { font-family: "Space Grotesk"; font-style: normal; font-display: swap; font-weight: 700;
src: url("@fontsource/space-grotesk/files/space-grotesk-latin-700-normal.woff2") format("woff2"); }
/* JetBrains Mono: 600 und 700 gab es bisher NICHT — die 19 `font-mono font-bold/semibold`
Stellen wurden vom Browser synthetisch fettgerechnet (verschmiert). Bei Monospace ist die
Laufweite über alle Schnitte gleich, das Nachrüsten verschiebt also kein Layout. */
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 400;
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2") format("woff2"); }
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 500;
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2") format("woff2"); }
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 600;
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff2") format("woff2"); }
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 700;
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff2") format("woff2"); }
/* Design-System: EINE Akzentfarbe Teal, bewusst dark-only (Glassmorphismus-Look).
Karten-Optik lebt in .mc-card / .mc-card-sm (unten) — Komponenten sagen es explizit,
statt dass CSS Utility-Klassen-Kombinationen errät. */
:root {
--background: 224 30% 6%;
--foreground: 210 20% 92%;
--card: 224 25% 10%;
--card-foreground: 210 20% 94%;
--popover: 224 28% 9%;
--popover-foreground: 210 20% 94%;
--primary: 172 72% 50%;
--primary-foreground: 224 47% 8%;
--muted: 220 16% 14%;
--muted-foreground: 215 14% 64%;
--accent: 220 16% 16%;
--accent-foreground: 210 20% 94%;
--border: 222 18% 23%;
--input: 222 18% 23%;
--ring: 172 72% 50%;
--radius: 0.75rem;
color-scheme: dark;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--font-sans: "Barlow", "Segoe UI", system-ui, sans-serif;
--font-heading: "Barlow Condensed", "Barlow", "Segoe UI", sans-serif;
--font-anzeige: "Barlow Condensed", "Barlow", "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-panel: var(--panel);
--color-erhaben: var(--erhaben);
--color-linie: var(--linie);
--color-linie-stark: var(--linie-stark);
--color-text-2: var(--text-2);
--color-text-3: var(--text-3);
--color-bernstein: var(--bernstein);
--color-bernstein-hell: var(--bernstein-hell);
--color-bernstein-grund: var(--bernstein-grund);
--color-bernstein-lampe: var(--bernstein-lampe);
--color-gruen: var(--gruen);
--color-gruen-text: var(--gruen-text);
--color-gruen-grund: var(--gruen-grund);
--color-gruen-rand: var(--gruen-rand);
--color-cyan: var(--cyan);
--color-cyan-text: var(--cyan-text);
--color-cyan-grund: var(--cyan-grund);
--color-cyan-rand: var(--cyan-rand);
--color-rot: var(--rot);
--color-rot-text: var(--rot-text);
--color-rot-grund: var(--rot-grund);
--color-rot-rand: var(--rot-rand);
--color-aus-grund: var(--aus-grund);
--color-aus-rand: var(--aus-rand);
--color-aus-text: var(--aus-text);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--font-space: "Space Grotesk", sans-serif;
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
}
/* Feste Viewport-Höhe: die App scrollt intern (main), nicht als Seite.
100dvh statt 100% — auf Mobilbrowsern mit einfahrender Adressleiste ist 100 % nicht
die sichtbare Höhe, der untere Rand (Statuszeile) verschwand darunter.
Die 100%-Zeile bleibt als Rückfall für Browser ohne dvh-Einheit stehen. */
html,
body,
#root {
height: 100%;
height: 100dvh;
overflow: hidden;
:root {
color-scheme: dark;
--radius: 0.625rem;
--background: #0e1012;
--foreground: #e6eaee;
--card: #15181b;
--card-foreground: #e6eaee;
--popover: #15181b;
--popover-foreground: #e6eaee;
--primary: #ffb020;
--primary-foreground: #1a1300;
--secondary: #1c2126;
--secondary-foreground: #e6eaee;
--muted: #1c2126;
--muted-foreground: #8b949e;
--accent: #1c2126;
--accent-foreground: #e6eaee;
--destructive: #ff5a4f;
--border: #262b30;
--input: #3a4148;
--ring: #5cc8ff;
--panel: #15181b;
--erhaben: #1c2126;
--linie: #262b30;
--linie-stark: #3a4148;
--text-2: #aeb6bf;
--text-3: #8b949e;
--bernstein: #ffb020;
--bernstein-hell: #ffc24d;
--bernstein-grund: #2a1e05;
--bernstein-lampe: #3a2a08;
--gruen: #3ddc8a;
--gruen-text: #7cc9a0;
--gruen-grund: #0f2319;
--gruen-rand: #1f5a3b;
--cyan: #5cc8ff;
--cyan-text: #93d5f8;
--cyan-grund: #0c2230;
--cyan-rand: #2f7fb0;
--rot: #ff5a4f;
--rot-text: #ff9a92;
--rot-grund: #2e0f0d;
--rot-rand: #a33a33;
--aus-grund: #171b1f;
--aus-rand: #2e343a;
--aus-text: #9aa3ad;
}
body {
margin: 0;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
* {
border-color: hsl(var(--border) / 0.85);
outline-color: hsl(var(--primary) / 0.5);
}
/* Sichtbarer Fokus für Tastatur-Nutzer auf allen interaktiven Elementen. */
:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.7);
outline-offset: 2px;
}
/* Eingabefelder: Teal-Fokus statt Browser-Default (ungeschichtet → gewinnt ohne !important). */
input:focus, textarea:focus, select:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15);
outline: none;
}
/* Schmale Teal-Scrollbars im Glas-Look (EINE Definition). */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--primary) / 0.25);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--primary) / 0.5);
}
/* ==========================================================================
Karten-Optik (ehem. „HUD 3.0"-Layer, jetzt explizite Klassen ohne !important;
ungeschichtete Regeln schlagen Tailwind-Utilities von selbst)
========================================================================== */
/* Große Inhalts-Karte: Glas-Grund, Farbverlaufs-Linie oben, sanftes Anheben bei Hover. */
.mc-card {
position: relative;
overflow: hidden;
border-radius: 1rem;
border: 1px solid hsl(var(--border) / 0.95);
background-color: hsl(var(--card) / 0.85);
box-shadow: 0 10px 30px -15px rgb(0 0 0 / 0.45), inset 0 1px 0 0 rgb(255 255 255 / 0.05);
transition:
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.mc-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3.5px;
background: linear-gradient(90deg, hsl(var(--primary)), #6366f1, #a855f7);
opacity: 0.75;
transition: opacity 0.3s ease;
}
.mc-card:hover {
transform: translateY(-3px);
background-color: hsl(var(--card) / 0.92);
border-color: hsl(var(--primary) / 0.35);
box-shadow: 0 20px 40px -20px rgb(0 0 0 / 0.65), 0 0 18px 2px hsl(var(--primary) / 0.05);
}
.mc-card:hover::before {
opacity: 1;
}
/* Kleine Karte / Listen-Container: ruhiger, ohne Verlaufs-Linie und Anheben. */
.mc-card-sm {
border-radius: 0.75rem;
border: 1px solid hsl(var(--border) / 0.9);
background-color: hsl(var(--card) / 0.88);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.mc-card-sm:hover {
background-color: hsl(var(--card) / 0.95);
border-color: hsl(var(--primary) / 0.3);
}
/* Aktiver Navigations-Eintrag in der Sidebar (ausgeklappt / eingeklappt). */
.nav-active {
background: linear-gradient(90deg, hsl(var(--primary) / 0.18), rgb(99 102 241 / 0.12));
color: hsl(var(--primary));
border-left: 3.5px solid hsl(var(--primary));
border-radius: 0 var(--radius) var(--radius) 0;
padding-left: calc(0.75rem - 3.5px);
box-shadow: inset 0 1px 0 0 rgb(255 255 255 / 0.02);
}
.nav-active-collapsed {
background: hsl(var(--primary) / 0.18);
color: hsl(var(--primary));
box-shadow: 0 0 12px 1px hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.3);
}
/* SVG-Fluss-Animation (Modell-Cockpit-Verdrahtung). */
@keyframes flow-cyan {
0% {
stroke-dasharray: 6 3;
stroke-dashoffset: 18;
@layer base {
* {
@apply border-border outline-ring/50;
}
100% {
stroke-dasharray: 6 3;
stroke-dashoffset: 0;
}
}
.animate-flow-cyan {
animation: flow-cyan 1.2s linear infinite;
}
/* Bewegungs-Sensibilität respektieren: Animationen/Transitions praktisch aus. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* WQHD & 4K Monitor Responsive Scaling (Fluid Typography & UI Scaling) */
@media (min-width: 1440px) {
html {
font-size: 16.5px;
@apply font-sans;
-webkit-font-smoothing: antialiased;
}
body {
@apply bg-background text-foreground;
margin: 0;
}
:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 2px;
}
}
@media (min-width: 1920px) {
html {
font-size: 17.5px;
@layer utilities {
/* Beschriftung wie auf einem Instrumentenbrett: schmal, groß, gesperrt. */
.schild {
font-family: var(--font-anzeige);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.16em;
}
.ziffern {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
}
@media (min-width: 2560px) {
html {
font-size: 19px;
}
/* Leuchtende Lampen: nur ein ruhiges Atmen, und nur wenn das System Bewegung erlaubt. */
@keyframes lampe-atmen {
0%, 100% { box-shadow: 0 0 10px color-mix(in srgb, var(--bernstein) 25%, transparent); }
50% { box-shadow: 0 0 22px color-mix(in srgb, var(--bernstein) 45%, transparent); }
}
@media (min-width: 3440px) {
html {
font-size: 20px;
}
@media (prefers-reduced-motion: no-preference) {
.lampe-atmet { animation: lampe-atmen 3.2s ease-in-out infinite; }
}
+173
View File
@@ -0,0 +1,173 @@
// Alle Abfragen und Aktionen der Oberfläche an einem Ort (TanStack Query).
//
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
// Sicherheitsnetz und laufen fünfmal seltener.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api, post } from "./api"
import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
AktionsErgebnis, Job, Modell, Nutzung, Radar, Sicherung, Start,
} from "./typen"
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
export const useStart = () =>
useQuery({ queryKey: ["start"], queryFn: () => api<Start>("/api/start"), refetchInterval: takt(20_000) })
export const useModelle = () =>
useQuery({
queryKey: ["models"],
queryFn: () => api<{ models: Modell[]; running: string[] }>("/api/models"),
refetchInterval: takt(30_000),
})
export const useNutzung = () =>
useQuery({ queryKey: ["nutzung"], queryFn: () => api<Nutzung>("/api/modelle/nutzung"), refetchInterval: 600_000 })
export const useRadar = () =>
useQuery({ queryKey: ["radar"], queryFn: () => api<Radar>("/api/radar"), retry: false, refetchInterval: 300_000 })
export const useJobs = () =>
useQuery({
queryKey: ["jobs"],
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
// Läuft etwas, wollen wir den Fortschritt sehen; sonst reicht der Strom.
refetchInterval: (q) =>
q.state.data?.jobs.some((j) => j.state === "running" || j.state === "queued") ? 2000 : takt(30_000)(),
})
export const useSicherungen = () =>
useQuery({
queryKey: ["sicherungen"],
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"),
})
export interface UpdateDetails {
kind: string
installed_build?: number | null
latest_build?: number | null
latest_tag?: string | null
count?: number
packages?: { name: string; current: string; candidate: string }[]
summary?: string
action_needed?: boolean | null
action_text?: string
error?: string
}
export const useUpdateDetails = (art: string | null) =>
useQuery({
queryKey: ["update-details", art],
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${art}`),
enabled: !!art,
staleTime: 600_000,
})
export interface UpdateLauf {
start: string | null
ende: string | null
status: string
fehler: string | null
job: string
}
export const useUpdateVerlauf = () =>
useQuery({ queryKey: ["updates-verlauf"], queryFn: () => api<{ laeufe: UpdateLauf[] }>("/api/updates/verlauf") })
export const useProtokoll = (dienst: string | null) =>
useQuery({
queryKey: ["protokoll", dienst],
queryFn: () => api<{ ok: boolean; text?: string; err?: string }>(
`/api/maintenance/logs?service=${encodeURIComponent(dienst ?? "")}&lines=200`),
enabled: !!dienst,
})
export interface Dienst {
name: string
unit: string
ok: boolean
scope: string
}
export const useDienste = () =>
useQuery({ queryKey: ["dienste"], queryFn: () => api<{ services: Dienst[] }>("/api/system/services") })
// --- Aktionen ----------------------------------------------------------------
function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
fn: (v: V) => Promise<E>,
opt: { erfolg: (v: V, e: E) => string; neuLaden: string[] },
) {
const qc = useQueryClient()
return useMutation({
mutationFn: fn,
onSuccess: (e, v) => {
const ergebnis = e as AktionsErgebnis
if (ergebnis && ergebnis.ok === false) {
melden("fehler", ergebnis.detail || ergebnis.err || "Das hat nicht geklappt.")
} else {
melden("erfolg", opt.erfolg(v, e))
}
for (const k of opt.neuLaden) qc.invalidateQueries({ queryKey: [k] })
},
onError: (f: Error) => melden("fehler", f.message),
})
}
export const useHinweisAktion = () =>
useAktion(
({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) =>
post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`),
{ erfolg: (v) => `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, neuLaden: ["start"] },
)
const UPDATE_PFADE: Record<string, string> = {
alle: "/api/maintenance/update-all",
pruefen: "/api/maintenance/check-updates",
os: "/api/maintenance/os-update",
engine: "/api/maintenance/engine-update",
swap: "/api/maintenance/swap-update",
hermes: "/api/maintenance/hermes-update",
}
export const useUpdateStarten = () =>
useAktion((art: string) => post<AktionsErgebnis>(UPDATE_PFADE[art]), {
erfolg: (art) => (art === "pruefen" ? "Suche nach Neuem läuft." : "Update läuft. Den Fortschritt siehst du unten."),
neuLaden: ["jobs", "start"],
})
export const useJetztSichern = () =>
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), {
erfolg: () => "Sicherung erstellt.",
neuLaden: ["sicherungen", "start"],
})
export const useZurueckspielen = () =>
useAktion((datei: string) => post<AktionsErgebnis>("/api/zeitmaschine/restore", { file: datei }), {
erfolg: () => "Zurückspielen läuft. Die Dienste starten gleich neu.",
neuLaden: ["sicherungen"],
})
export const useModellLaden = () =>
useAktion((name: string) => post<AktionsErgebnis>(`/api/models/${encodeURIComponent(name)}/load`), {
erfolg: (name) => `${name} wird geladen.`,
neuLaden: ["models", "start"],
})
export const useRadarAktion = () =>
useAktion(
({ id, aktion }: { id: string; aktion: "uebernehmen" | "verwerfen"; name: string }) =>
post<AktionsErgebnis>(`/api/radar/${encodeURIComponent(id)}/${aktion}`),
{
erfolg: (v) => (v.aktion === "uebernehmen" ? `${v.name} übernommen.` : `${v.name} verworfen.`),
neuLaden: ["radar", "models", "start"],
},
)
export const useRadarSuche = () =>
useAktion(() => post<AktionsErgebnis>("/api/radar/suche"), {
erfolg: () => "Radar sucht nach neuen Modellen.",
neuLaden: ["radar"],
})
+78
View File
@@ -0,0 +1,78 @@
// Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne
// DOM testbar bleibt und Vites Hot-Reload die Komponenten-Dateien sauber tauschen kann.
import type { UpdateDetails } from "./abfragen"
import type { Modell, Start } from "./typen"
// --- Rundinstrument -----------------------------------------------------------------
// 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung.
const MX = 100
const MY = 95
const START = 210 // Grad, links unten
const SPANNE = 240
function punkt(grad: number, r: number): [number, number] {
const rad = (grad * Math.PI) / 180
return [MX + r * Math.cos(rad), MY - r * Math.sin(rad)]
}
const r2 = (n: number) => Math.round(n * 100) / 100
/** SVG-Pfad eines Bogens von Anteil `von` bis `bis` (0…1) auf Radius `r`. */
export function bogenPfad(von: number, bis: number, r: number): string {
const a = Math.max(0, Math.min(1, von))
const b = Math.max(a, Math.min(1, bis))
const [x0, y0] = punkt(START - SPANNE * a, r)
const [x1, y1] = punkt(START - SPANNE * b, r)
const gross = (b - a) * SPANNE > 180 ? 1 : 0
return `M ${r2(x0)} ${r2(y0)} A ${r} ${r} 0 ${gross} 1 ${r2(x1)} ${r2(y1)}`
}
/** Farbe der Temperatur: grün bis 70 °C, dann Bernstein, ab 85 °C rot. */
export function temperaturFarbe(grad: number | null | undefined) {
if (grad == null) return "var(--text-3)"
if (grad >= 85) return "var(--rot)"
if (grad >= 70) return "var(--bernstein)"
return "var(--gruen)"
}
// --- Hauptwarnleuchte ---------------------------------------------------------------
/** Was die Hauptwarnleuchte sagt. Der schweigende Wächter geht allem anderen vor. */
export function hauptleuchte(z: Start["zustand"]) {
if (!z.waechter_wach) {
return { art: "stumm" as const, oben: "Wächter", mitte: "SCHWEIGT", unten: "Seit über 5 Minuten kein Prüflauf" }
}
if (z.update_laeuft) {
return { art: "info" as const, oben: "Wartung", mitte: "UPDATE LÄUFT", unten: "Hinweise ruhen bis zum Ende" }
}
if (z.anzahl === 0) {
return { art: "ok" as const, oben: "Alles", mitte: "IN ORDNUNG", unten: "Keine offenen Hinweise" }
}
const mitte = `${z.anzahl} HINWEIS${z.anzahl === 1 ? "" : "E"}`
return z.stufe === "rot"
? { art: "rot" as const, oben: "Störung", mitte, unten: "Drücken zum Ansehen" }
: { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" }
}
// --- Updates ------------------------------------------------------------------------
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen. */
export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: string; neu: string | null } {
if (!d) return { laeuft: "…", neu: null }
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} Pakete` : null }
const pre = id === "engine" ? "b" : "v"
const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–"
const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build
return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null }
}
// --- Modelle ------------------------------------------------------------------------
/** Rolle eines Modells aus seinen llama-swap-Aliasen. */
export function rolleVon(m: Modell): "hirn" | "coder" | null {
const a = m.aliases.map((x) => x.toLowerCase())
if (a.includes("hermes") || a.includes("fast")) return "hirn"
if (a.includes("coder")) return "coder"
return null
}
-116
View File
@@ -1,116 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { ApiError, api } from "./api"
/** Ruft api() auf und gibt den erwarteten ApiError typisiert zurueck.
* Kommt kein Fehler, scheitert der Test hier statt spaeter an einer Zusicherung. */
async function fehlerVon(pfad: string, init?: RequestInit): Promise<ApiError> {
try {
await api(pfad, init)
} catch (e) {
if (e instanceof ApiError) return e
throw e
}
throw new Error(`api(${pfad}) hat keinen Fehler geworfen`)
}
// Antwort-Attrappe: `text()` liefert den Rumpf, den fehlerText() lesen soll.
function antwort(status: number, rumpf: string, ok = status < 400): Response {
return {
ok,
status,
statusText: status === 500 ? "Internal Server Error" : "Bad Request",
text: async () => rumpf,
json: async () => JSON.parse(rumpf),
} as unknown as Response
}
function fetchAttrappe(res: Response) {
const spion = vi.fn(async () => res)
vi.stubGlobal("fetch", spion)
return spion
}
afterEach(() => vi.unstubAllGlobals())
describe("api()", () => {
it("gibt den JSON-Rumpf zurueck, wenn alles gut geht", async () => {
fetchAttrappe(antwort(200, '{"ok":true,"wert":42}'))
await expect(api<{ wert: number }>("/api/test")).resolves.toEqual({ ok: true, wert: 42 })
})
// Der Kern von B-14: frueher warf der Helfer "500 Internal Server Error" und verwarf
// den Rumpf — der eigentliche Grund erreichte die Oberflaeche nie.
it("hebt FastAPIs `detail` in die Fehlermeldung", async () => {
fetchAttrappe(antwort(400, '{"detail":"Dienst \'llama-swap\' nicht erlaubt."}'))
await expect(api("/api/maintenance/restart", { method: "POST" }))
.rejects.toThrowError("Dienst 'llama-swap' nicht erlaubt.")
})
it("fasst FastAPI-Validierungslisten zu einem Satz zusammen", async () => {
fetchAttrappe(antwort(422, '{"detail":[{"msg":"repo fehlt"},{"msg":"quant ungueltig"}]}'))
await expect(api("/api/models/install", { method: "POST" }))
.rejects.toThrowError("repo fehlt; quant ungueltig")
})
it("faellt auf Status + Statustext zurueck, wenn der Rumpf leer ist", async () => {
fetchAttrappe(antwort(500, ""))
await expect(api("/api/test")).rejects.toThrowError("500 Internal Server Error")
})
it("laesst sich von einer HTML-Fehlerseite nicht aus dem Tritt bringen", async () => {
fetchAttrappe(antwort(502, "<html><body>Bad Gateway</body></html>"))
const fehler = await fehlerVon("/api/test")
expect(fehler.status).toBe(502)
// Kein zweiter Fehler beim Parsen — irgendeine brauchbare Zeile kommt heraus.
expect(typeof fehler.message).toBe("string")
expect(fehler.message.length).toBeGreaterThan(0)
})
it("traegt status und detail am Fehlerobjekt", async () => {
fetchAttrappe(antwort(400, '{"detail":"kaputt"}'))
const fehler = await fehlerVon("/api/test")
expect(fehler.status).toBe(400)
expect(fehler.detail).toBe("kaputt")
})
})
// ── Sicherheits-Wache (B-01) ────────────────────────────────────────────────
// Bis zum 28.08.2026 las api() das Box-Sudo-Passwort und den HF-Token aus dem
// localStorage und haengte sie an jeden mutierenden Request — als Header UND in den
// JSON-Rumpf. Da der Dienst-Nutzer auf der Box mit NOPASSWD: ALL laeuft, war ein XSS
// in dieser SPA gleichbedeutend mit Root. Diese Tests sind der Zaun davor: Wer die
// Bequemlichkeit je wieder einbaut, macht sie rot.
describe("api() traegt keine Geheimnisse", () => {
it("schickt keine Kopfzeilen und keinen Rumpf aus dem localStorage", async () => {
localStorage.setItem("mc_sudo_password", "geheim123")
localStorage.setItem("mc_hf_token", "hf_geheim")
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
await api("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: "llama-swap" }),
})
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
const kopfzeilen = JSON.stringify(init.headers ?? {})
const rumpf = String(init.body ?? "")
expect(kopfzeilen.toLowerCase()).not.toContain("sudo")
expect(kopfzeilen).not.toContain("geheim123")
expect(rumpf).not.toContain("geheim123")
expect(rumpf).not.toContain("hf_geheim")
expect(rumpf).not.toContain("sudo_password")
expect(rumpf).not.toContain("hf_token")
// Der Rumpf des Aufrufers bleibt unveraendert — nichts wird heimlich angereichert.
expect(JSON.parse(rumpf)).toEqual({ service: "llama-swap" })
})
it("erfindet auch ohne Rumpf keinen", async () => {
localStorage.setItem("mc_sudo_password", "geheim123")
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
await api("/api/maintenance/reboot", { method: "POST" })
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
expect(init.body ?? null).toBeNull()
})
})
+40 -635
View File
@@ -1,644 +1,49 @@
// Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*).
//
// v3-Umbau P1 (28.08.2026): Hier wurden frueher zwei Geheimnisse aus dem localStorage
// gelesen und an jeden mutierenden Request gehaengt — das Box-Sudo-Passwort (als Header
// UND im JSON-Rumpf) und der HuggingFace-Token. Da der Dienst-Nutzer auf der Box mit
// `NOPASSWD: ALL` laeuft, war ein XSS in dieser SPA gleichbedeutend mit Root.
// Das Sudo-Passwort ist ersatzlos weg (auf der Box gemessen: `sudo -n true` laeuft durch,
// es wurde nie gebraucht), der HF-Token liegt jetzt auf der Box (services/geheimnisse.py).
// Diese Datei kennt kein Geheimnis mehr — und soll auch nie wieder eines kennen.
// Dünner Zugriff auf das MC2-Backend. Bewusst ohne Anmelde- oder Geheimnis-Felder:
// Nichts, was der Browser speichert, reist hier mit (Lehre aus v3-Umbau P1).
/** Fehler mit dem, was das Backend WIRKLICH gesagt hat.
*
* Vorher warf der Helfer `new Error("500 Internal Server Error")` und verwarf den Rumpf.
* FastAPI legt den eigentlichen Grund aber in `detail` — der erreichte die Oberflaeche nie,
* und der Nutzer las "500" statt "Dienst 'llama-swap' nicht erlaubt". */
export class ApiError extends Error {
readonly status: number
readonly detail: string | null
constructor(status: number, statusText: string, detail: string | null) {
super(detail || `${status} ${statusText}`)
this.name = "ApiError"
this.status = status
this.detail = detail
export class ApiFehler extends Error {
constructor(
public status: number,
public detail: string,
) {
super(detail)
this.name = "ApiFehler"
}
}
/** Liest den Fehler-Rumpf, ohne selbst zu scheitern: Ein kaputter oder leerer Rumpf darf
* die Fehlermeldung nicht in einen zweiten Fehler verwandeln. */
async function fehlerText(res: Response): Promise<string | null> {
/** FastAPI legt Fehlertexte in `detail` ab — manchmal als Text, manchmal als Liste. */
function detailAus(body: unknown, status: number): string {
if (body && typeof body === "object" && "detail" in body) {
const d = (body as { detail: unknown }).detail
if (typeof d === "string") return d
if (Array.isArray(d)) return d.map((x) => (x as { msg?: string }).msg ?? String(x)).join("; ")
}
return `Die Box antwortet mit Fehler ${status}.`
}
export async function api<T>(pfad: string, init: RequestInit = {}): Promise<T> {
let antwort: Response
try {
const roh = await res.text()
if (!roh) return null
try {
const d = JSON.parse(roh)
const detail = d?.detail ?? d?.err ?? d?.message
if (typeof detail === "string" && detail.trim()) return detail.trim()
if (Array.isArray(detail) && detail.length) {
// FastAPI-Validierungsfehler: Liste aus {loc, msg, type}
return detail.map((e: any) => e?.msg).filter(Boolean).join("; ") || null
}
return null
} catch {
// Kein JSON (HTML-Fehlerseite eines Proxys o. AE.) — erste Zeile reicht als Hinweis.
return roh.slice(0, 200).split("\n")[0].trim() || null
}
antwort = await fetch(pfad, {
...init,
headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
})
} catch {
return null
throw new ApiFehler(0, "MC2 ist nicht erreichbar. Läuft die Box?")
}
const text = await antwort.text()
const body = text ? safeJson(text) : null
if (!antwort.ok) throw new ApiFehler(antwort.status, detailAus(body, antwort.status))
return body as T
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text)
} catch {
return text
}
}
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
})
if (!res.ok) throw new ApiError(res.status, res.statusText, await fehlerText(res))
return res.json() as Promise<T>
}
// llama-swap-`groups`: Mitglieder mit swap=false dürfen GLEICHZEITIG resident sein (Ko-Residenz).
// Die `brains`-Gruppe hält Hirn (fast) + Augen (vision) gemeinsam warm, statt sich zu verdrängen.
export interface GroupSpec {
swap: boolean
persist: boolean
members: string[]
}
export interface GroupsResp {
groups: Record<string, GroupSpec>
}
export const getGroups = () => api<GroupsResp>("/api/groups")
export const setGroup = (group: string, members: string[], swap = false, persist = true) =>
api("/api/groups", { method: "PUT", body: JSON.stringify({ group, members, swap, persist }) })
export interface Capabilities {
moe: boolean
active_b: number | null
tools: "yes" | "likely" | "no"
vision: boolean
coder: boolean
reasoning: boolean
embedding: boolean
ctx: number | null
params_b: number | null
arch: string | null
}
export interface Fit {
level: "perfect" | "marginal" | "too_tight"
text: string
req_gb: number
tps: number
}
export interface RoleRecModel {
name: string
current_role: string | null
params_b: number
quant: string
fit: Fit
suitable: boolean
incomplete: boolean
score: number
reason: string
recommended: boolean
}
export interface RoleRecResp {
role: string
recommended: string | null
models: RoleRecModel[]
}
export interface FitResp {
params_b: number
fit: Fit
optimal_ctx: number
assigned_ctx: number
budget: { gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string }
sys_ram_gb: number
}
export interface ModelInfo {
name: string
role: string | null
aliases: string[]
api_ids: string[]
ctx: number | null
ttl: number | null
cmd: string
gguf_path: string
filename: string
quant: string
size_bytes: number | null
incomplete: boolean
prompt_cache: boolean
spec_draft_model: string | null
spec_type: string | null
spec_active: boolean
parallel_slots: number
capabilities: Capabilities
}
export interface VocabFingerprint {
model: string | null
pre: string | null
n_vocab: number | null
arch: string | null
}
export interface DraftInfo {
path: string
filename: string
size_bytes: number | null
vocab: VocabFingerprint | null
compatible: boolean | null // null = nicht prüfbar (Ziel-GGUF fehlt)
mtp?: boolean // MTP-Kopf (Multi-Token-Prediction) statt klassischem Draft
}
export interface DraftsResp {
target_path: string
target_exists: boolean
target_vocab: VocabFingerprint | null
drafts: DraftInfo[]
}
export interface DiscoverModel {
name: string
author: string
repo: string
role: string
params_b: number
quant: string
tags: string[]
downloads: number
fit: Fit
optimal_ctx: number
caps: Capabilities
}
export interface DiscoverCategory {
role: string
title: string
icon: string
models: DiscoverModel[]
recommended: string | null
}
export interface DiscoverResp {
updated: number
categories: DiscoverCategory[]
sys_ram_gb: number
}
export interface RoutingResp {
mode?: string
endpoint?: string
heavy_threshold_chars?: number
routes: { name: string; target: string }[]
lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[]
fallbacks: Record<string, string[]>[]
context_window_fallbacks: Record<string, string[]>[]
gateway_reachable: boolean
}
// UI-editierbare Routing-Policy (GET/PUT /api/routing/policy). Aliase + Zeichen-Schwellen
// hinter den Lanes; hot-reload im Backend (kein Restart).
export interface RoutingPolicy {
fast: string
heavy: string
coder: string
coder_lite: string
heavy_chars: number
coding_escalate_chars: number
fast_no_think: boolean
}
export interface RoutingPolicyField {
key: keyof RoutingPolicy
label: string
type: "str" | "int" | "bool"
min?: number
max?: number
}
export interface RoutingPolicyMeta {
policy: RoutingPolicy
defaults: RoutingPolicy
fields: RoutingPolicyField[]
}
export const getRoutingPolicy = () => api<RoutingPolicyMeta>("/api/routing/policy")
export const updateRoutingPolicy = (patch: Partial<RoutingPolicy>) =>
api<{ policy: RoutingPolicy }>("/api/routing/policy", { method: "PUT", body: JSON.stringify(patch) })
export interface GitInfo {
hash: string
date: string
subject: string
branch: string
dirty: boolean
path: string
}
// Engine kann git-, binary- oder unbekannte Version sein — Felder je nach `type`.
export interface ComponentVersion {
type: "git" | "binary" | "unknown"
hash?: string
date?: string
subject?: string
branch?: string
dirty?: boolean
path?: string
version_text?: string
}
export interface Versions {
mc2: GitInfo | null
engine: ComponentVersion
hermes_agent: GitInfo | null
}
export interface SystemStatus {
cpu: { percent: number; cores: number | null }
ram: { total: number; used: number; percent: number }
gpu: {
busy_percent: number | null
vram_used: number | null
vram_total: number | null
gtt_used?: number | null
gtt_total?: number | null
} | null
temp: { cpu?: number; gpu?: number } | null
disk: { total: number; used: number; percent: number } | null
/** Sekunden seit dem Systemstart (v3-Umbau P3). null, wenn die Box es nicht liefert —
* die Statusleiste blendet das Feld dann aus, statt eine Zahl zu erfinden. */
uptime_s?: number | null
versions?: Versions
}
export interface ServiceInfo {
name: string
unit: string // echter systemd-Name — Basis für Restart/Logs/Matching (kein Namens-Raten)
url: string
ok: boolean
scope: "user" | "system"
}
export interface ServicesResp {
services: ServiceInfo[]
links: { engine_ui: string; gateway: string; hermes_ui: string }
}
export interface ComponentUpdate {
key: string // z.B. "hermes_agent"
name: string
current: string | null
latest: string | null
update: boolean | null // null = unbestimmbar (installierte Version remote nicht abfragbar)
reachable: boolean | null
}
export interface UpdatesResp {
os: number
engine: number
swap: number
models: number
model_list: { role: string; title: string; repo: string }[]
last_check?: number | null
components?: ComponentUpdate[]
}
export interface UpdateDetails {
kind: "os" | "engine" | "swap" | "hermes"
error?: string
// LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst)
summary?: string
// Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary)
action_needed?: boolean | null
action_text?: string
// os
count?: number
packages?: { name: string; current: string; candidate: string }[]
// os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt"
held_back?: { name: string; reason: "phasing" | "kept_back" }[]
// engine
installed_build?: number | null
latest_build?: number | null
latest_tag?: string | null
name?: string | null
url?: string | null
body?: string | null
// hermes
branch?: string | null
behind?: number
commits?: { hash: string; subject: string; when: string }[]
}
export interface ConnectTool {
label: string
lang: string
snippet: string
note: string
}
export interface ConnectResp {
host: string
gateway_url: string
mc_url: string
tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway)
}
export interface ConnectLine {
ok: boolean
detail: string
}
export interface ConnectHealth {
gateway: ConnectLine
memory: ConnectLine // Leitung 2 — Hermes-natives Gedächtnis (nur Status, nichts zu konfigurieren)
desktop_gateway: ConnectLine
}
// Graph-Ansicht des Wissens-Vaults (GET /api/wissen/graph) — trägt aus historischen Gründen
// noch „Memory" im Namen; die Daten kommen aus dem Vault, nicht aus einem Gedächtnis-Dienst.
export interface MemoryGraphNode {
id: string
content: string
category: string
source: string
}
export interface MemoryGraph {
nodes: MemoryGraphNode[]
edges: { source: string; target: string; weight: number }[]
}
export interface AgentStatus {
gateway_url: string
box_console_url?: string
hermes_ui_url?: string
gateway_reachable: boolean
box_console_reachable?: boolean
hermes_ui_reachable?: boolean
home_exists: boolean
brain_model?: string
has_config: boolean
has_skills: boolean
has_memories: boolean
telegram_enabled?: boolean
mcp_server_count?: number
pc_executor_reachable?: boolean
pc_executor_url?: string
}
export interface Job {
id: string
label: string
group?: string | null // "maintenance" = system-veränderndes Update (Wartungs-Riegel)
state: "queued" | "running" | "done" | "failed" | "canceled"
progress?: number
total_bytes?: number
done_bytes?: number
rate_bps?: number
eta_s?: number
}
// Werkzeug-Verlauf des Agenten (GET /api/agent/aktivitaet, v3-Umbau P6).
// Gelesen aus Hermes' Log — Denkstrom und Werkzeug-Argumente stehen dort NICHT und
// tauchen darum auch hier nicht auf.
export interface WerkzeugRuf {
zeit: string
lauf: string | null
werkzeug: string
dauer_s: number
zeichen: number | null
ok: boolean
fehler: string | null
}
export interface WerkzeugBilanz {
werkzeug: string
rufe: number
fehler: number
dauer_schnitt_s: number
letzter_fehler: string | null
}
export interface AgentLauf {
lauf: string
von: string
bis: string
rufe: number
fehler: number
}
export interface AktivitaetResp {
verfuegbar: boolean
pfad: string
rufe: WerkzeugRuf[]
werkzeuge: WerkzeugBilanz[]
laeufe: AgentLauf[]
}
export interface Health {
status: string
version: string
engine_reachable: boolean
gateway_reachable: boolean
brain?: { role: string; model: string | null; ready: boolean }
}
// 24-h-Metrik-Verlauf (GET /api/system/history?minutes=N) — Basis der Cockpit-Zeitachse.
// tp/tc sind Token-GESAMTZÄHLER; Raten rechnet das Frontend aus den Deltas.
export interface HistoryPoint {
t: number // Epoch-Sekunden
cpu: number | null
ram: number | null
gpu: number | null
disk: number | null
tp: number | null
tc: number | null
}
export interface HistoryResp {
sample_s: number
points: HistoryPoint[]
}
export interface TokenStats {
prompt_tokens: number
completion_tokens: number
total_tokens: number
saved_usd: number
saved_eur: number
pricing?: Record<string, { in: number; out: number }>
}
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
// stt · vision · hirn · gen. Der Gedächtnis-Abruf läuft seit der Mem0-Ablösung Hermes-intern
// (kein Rückruf an MC2) und steckt daher unsichtbar in hirn_ms.
export interface VoiceTurn {
id: string
ts: number // Epoch-Sekunden
session: string
kind: string
had_images: boolean
stt_ms: number | null
vision_ms: number | null
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Gedächtnis + LLM-TTFT)
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
total_ms: number
error: string | null
}
export interface VoiceTraceResp {
turns: VoiceTurn[]
}
export interface ModelsResp {
models: ModelInfo[]
running?: string[]
}
// ── Ideen-Queue (natives Hermes-Kanban) ──────────────────────────────────────
export interface IdeenItem {
id: string
titel: string
body: string
status: string // triage | todo | ready | running | blocked | review | done
assignee: string | null
welt?: string // "box" = Werkstatt baut · "ide" = für Zed vorbereitet
art?: string // "box" | "idee" (nur durchdacht) | "projekt" (Repo vorbereitet)
erstellt: number | null
fertig: number | null
von: string | null
frage?: string | null // bei status=blocked: die Frage/der Grund des Workers
frage_kind?: string | null // needs_input | capability | transient | ""
eltern?: string[] // offene Vorgänger-Karten (task_links) — leer = startbereit
wartet_auf?: string[] // Titel der offenen Vorgänger (max. 3, für die Anzeige)
notiz?: string | null // bei running: jüngste Heartbeat-Notiz des Workers
projekt?: string | null // Familien-Schlüssel (verkettete Karten) — null = Einzelkarte
prio?: number // Dispatcher-Reihenfolge: priority DESC, created ASC
puls_alter?: number | null // bei running: Sekunden seit letztem Lebenszeichen (Hänger-Warnung)
gestartet?: number | null // bei running: started_at (Epoch)
}
// Karten-Familie (verkettete Karten = ein Projekt) für die gruppierte Queue-Sicht
export interface IdeenProjekt {
key: string
titel: string
gesamt: number
fertig: number
laeuft: number
haengt: number
}
export interface IdeenLogResp {
available: boolean
lines: string[]
}
export interface IdeenResp {
available: boolean
items: IdeenItem[]
offen: number
fehler?: string
projekte?: IdeenProjekt[]
}
// Ergebnis einer fertigen Queue-Aufgabe (Abschluss-Zusammenfassung des Workers)
export interface IdeenErgebnis {
available: boolean
summary: string
result: string
}
// Konzept einer IDE-Karte — aus dem Gitea-Repo oder aus ~/konzepte auf der Box
export interface IdeenKonzept {
available: boolean
repo?: string | null
clone_url?: string
quelle?: "repo" | "datei"
pfad?: string // nur quelle=datei: Pfad auf der Box
datei?: string
konzept?: string
error?: string
}
// ── Chronik (Timeline aus dem Melde-Briefkasten) ─────────────────────────────
export interface ChronikItem {
id: number
ts: number
subject: string
text: string
source: string
priority: "normal" | "silent"
}
export interface ChronikResp {
items: ChronikItem[]
}
// ── Wissens-Vault (Traum-Notizen, read-only) ─────────────────────────────────
export interface WissenFile {
path: string
name: string
dir: string
title: string
mtime: number
neu: boolean
}
export interface WissenResp {
available: boolean
files: WissenFile[]
}
export interface WissenDatei {
path: string
content: string
mtime: number
}
// ── Zeitmaschine (Snapshots + Restore) ───────────────────────────────────────
export interface Snapshot {
snapshot: string
file: string
size_mb: number
}
export interface ZeitmaschineResp {
available: boolean
backups: Snapshot[]
}
// ── Erinnerungen (Wecker/Routinen) ───────────────────────────────────────────
export interface Reminder {
id: number
text: string
next_ts: number
repeat: string // '' einmalig | daily | weekdays | weekly
created_ts: number
}
export interface HermesBrainModel {
name: string
filename?: string
params_b: number | null
quant?: string
size_bytes?: number | null
version?: number | null
gguf_path?: string
incomplete?: boolean
}
export interface HermesBrainCandidate {
repo: string
name: string
version: number
params_b: number
downloads: number
fit: Fit
}
export interface HermesBrainBudget {
gtt_gb: number
brain_gb: number // Footprint des (empfohlenen) Brains, das immer resident bleibt
warm_projected_gb: number // alle persist-Modelle warm (Info)
free_after_gb: number // frei nach Brain + größtem on-demand
largest_ondemand_gb: number // größtes on-demand-Modell (z.B. heavy/coder)
fits: boolean // passt Brain + größtes on-demand zusammen ins Budget?
}
export interface HermesBrainResp {
current: HermesBrainModel | null
recommended: HermesBrainCandidate | null
update_available: boolean
budget?: HermesBrainBudget | null
}
// ── Ideen-Log (Live Worker Log) ──────────────────────────────────────────────
export const getIdeenLog = (id: string) => api<IdeenLogResp>(`/api/ideen/${id}/log`)
export const post = <T>(pfad: string, daten?: unknown) =>
api<T>(pfad, { method: "POST", body: daten === undefined ? undefined : JSON.stringify(daten) })
-95
View File
@@ -1,95 +0,0 @@
// Ereignisstrom — Gegenstück zu backend/routers/events.py.
//
// EIN EventSource auf /api/stream. Zwei Ereignisarten:
// · `invalidate` stößt gezielt die betroffenen React-Query-Caches an
// · `metrik` schreibt jede Sekunde direkt in den Metrik-Speicher (v3-Umbau P4)
//
// Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz (queries.ts entspannt
// sie ×5 über sseVerbunden()). Reißt er, reconnectet EventSource von selbst — bis dahin
// pollt die UI wie früher, und die Statusleiste sagt es ehrlich.
//
// v3-Umbau P4: Die Messpunkte kommen jetzt GEPUSHT statt gepollt. Vorher fragten
// `system-status` und `token-stats` im 3-Sekunden-Takt nach — zwei Dauer-Anfragen,
// unabhängig davon, ob sich etwas geändert hatte. Beide Poller sind ersatzlos weg,
// solange der Strom steht.
//
// WICHTIG (der Grund, warum der Metrik-Speicher kein Store ist): `metrik` feuert jede
// Sekunde. Der Speicher liegt bewusst AUSSERHALB von React (lib/metricsStore.ts,
// useSyncExternalStore) — so rendern nur die Karten neu, die den Verlauf wirklich
// abonnieren, nicht die ganze Shell.
import { useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { melden, stromEreignis, stromLageSetzen } from "@/app/store"
import { pushMetrik } from "@/lib/metricsStore"
let verbunden = false
export const sseVerbunden = () => verbunden
/** Rohform eines `metrik`-Ereignisses (siehe services/system.py → metrik_punkt). */
export interface MetrikPunkt {
cpu: number
ram: number
ram_used: number
ram_total: number
gpu: number | null
disk: number | null
temp_cpu: number | null
temp_gpu: number | null
uptime_s: number | null
/** GESAMTZÄHLER, keine Rate — die rechnet der Speicher aus zwei Punkten selbst. */
tok_p: number
tok_c: number
}
export function useEventStream() {
const qc = useQueryClient()
useEffect(() => {
const es = new EventSource("/api/stream")
// Der erste Verbindungsaufbau ist der Normalfall und wird NICHT gemeldet — sonst
// begrüßt jede Seite den Nutzer mit „Verbindung wieder da". Gemeldet wird nur der
// Übergang: einmal beim Reißen, einmal beim Heilen.
let warSchonMalVerbunden = false
es.onopen = () => {
verbunden = true
stromLageSetzen("live")
if (warSchonMalVerbunden) melden("erfolg", "Verbindung zur Zentrale ist wieder da.")
warSchonMalVerbunden = true
// Beim (Wieder-)Verbinden einmal alles nachziehen: Was während der Trennung
// passiert ist, hat der Strom nicht gemeldet.
qc.invalidateQueries()
}
es.onerror = () => {
if (!verbunden) return
verbunden = false
// „nachlauf" statt „getrennt": EventSource reconnectet selbst, und bis dahin
// pollen die Queries weiter — die Zahlen sind also älter, aber nicht tot.
stromLageSetzen("nachlauf")
melden("warnung", "Verbindung zur Zentrale verloren — die Anzeigen laufen im Nachlauf.")
// Sofort auf den normalen Takt zurück, statt bis zum nächsten Refetch zu warten.
qc.invalidateQueries()
}
es.addEventListener("invalidate", (e) => {
try {
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
stromEreignis()
} catch { /* kaputtes Event bricht den Strom nicht */ }
})
es.addEventListener("metrik", (e) => {
try {
pushMetrik(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
} catch { /* ein kaputter Messpunkt darf den Strom nicht reißen */ }
})
return () => {
verbunden = false
stromLageSetzen("getrennt")
es.close()
}
}, [qc])
}
-38
View File
@@ -1,38 +0,0 @@
// Zentrale Formatierungs-Helfer (vorher in einzelnen Views dupliziert).
/** Bytes → GB als String mit einer Nachkommastelle (z.B. "14.1"). */
export function gb(b: number): string {
return (b / 1024 ** 3).toFixed(1)
}
/** Bytes → "1.2 GB" / "512 MB"; leer bei 0/undefined. */
export function fmtBytes(b?: number): string {
if (!b) return ""
if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
return `${(b / 1024 ** 2).toFixed(0)} MB`
}
/** Bytes → "1.2 GB" / "512 MB"; "—" bei 0/undefined/null. */
export function fmtSize(b?: number | null): string {
if (!b) return "—"
const g = b / 1024 ** 3
return g >= 1 ? `${g.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
}
/** Sekunden → "3 min" / "45 s"; leer bei 0/undefined. */
export function fmtEta(s?: number): string {
if (!s) return ""
const m = Math.floor(s / 60)
return m > 0 ? `${m} min` : `${s} s`
}
/** Kontextlänge → "32k"; "—" bei null. */
export function fmtCtx(c: number | null): string {
return c ? `${Math.round(c / 1024)}k` : "—"
}
/** Unix-Sekunden → "25.07., 21:04"; "—" bei 0/null/undefined. */
export function fmtWhen(ts: number | null | undefined): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
}
-70
View File
@@ -1,70 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest"
import { hashUmleiten } from "./hashUmleitung"
import { NAV, navFuerPfad } from "@/nav"
function adresseSetzen(url: string) {
window.history.replaceState(null, "", url)
}
// Diese Brücke ist die einzige Verbindung zu Lesezeichen aus der Hash-Zeit. Sie sieht
// nach totem Code aus und wäre damit ein naheliegender Kandidat zum Wegräumen — die
// Tests machen sichtbar, was dabei kaputtginge.
describe("hashUmleiten()", () => {
beforeEach(() => adresseSetzen("/"))
it("biegt jedes alte Hash-Lesezeichen auf seinen neuen Pfad", () => {
for (const eintrag of NAV) {
adresseSetzen("/#" + eintrag.id)
hashUmleiten()
expect(window.location.pathname, `#${eintrag.id}`).toBe(eintrag.pfad)
}
})
it("verträgt die Schreibweise mit Schrägstrich (#/models)", () => {
adresseSetzen("/#/models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
})
it("behält vorhandene Suchparameter", () => {
adresseSetzen("/?system=logs#models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
expect(window.location.search).toBe("?system=logs")
})
it("fasst einen unbekannten Hash nicht an", () => {
adresseSetzen("/cockpit#irgendwas")
hashUmleiten()
expect(window.location.pathname).toBe("/cockpit")
})
it("tut ohne Hash gar nichts", () => {
adresseSetzen("/modelle?reiter=routing")
hashUmleiten()
expect(window.location.pathname + window.location.search).toBe("/modelle?reiter=routing")
})
})
describe("navFuerPfad()", () => {
it("findet den Eintrag zum exakten Pfad", () => {
expect(navFuerPfad("/modelle")?.id).toBe("models")
expect(navFuerPfad("/cockpit")?.id).toBe("dashboard")
})
// Damit /ideen/<id> die Sidebar weiterhin bei „Ideen" markiert und nicht ins Leere fällt.
it("ordnet Unterpfade dem übergeordneten Eintrag zu", () => {
expect(navFuerPfad("/ideen/id_9f3c")?.id).toBe("ideen")
})
it("liefert nichts für einen unbekannten Pfad", () => {
expect(navFuerPfad("/gibtsnicht")).toBeUndefined()
})
// Der Grund für die Längen-Sortierung: ein kurzer Pfad darf einen längeren nicht
// überstimmen, sonst gewinnt der erstbeste Präfix-Treffer.
it("nimmt bei mehreren Treffern den längsten Pfad", () => {
const treffer = navFuerPfad("/modelle")
expect(treffer?.pfad).toBe("/modelle")
})
})
-26
View File
@@ -1,26 +0,0 @@
// Alte Hash-Lesezeichen auf die neuen Pfade umbiegen (v3-Umbau P2).
//
// Bis zum 28.08.2026 lebte die Ansicht im URL-Hash (`#models`). Das war teilbar und
// reload-fest — aber nur auf View-Ebene; für den Zustand DARUNTER (offene Idee, Reiter,
// Drawer) war kein Platz. Mit dem Router zieht das in echte Pfade um.
//
// Wer ein altes Lesezeichen hat, soll nichts merken: Diese Funktion läuft EINMAL vor dem
// Router-Mount und ersetzt `#models` durch `/modelle` — per `replaceState`, damit die
// Zurück-Taste nicht in einer Umleitungs-Schleife landet.
//
// Die Tabelle darf mit der Zeit schrumpfen, aber nicht kommentarlos: Solange jemand ein
// Lesezeichen von früher haben könnte, ist sie die einzige Brücke dorthin.
import { NAV, type ViewId } from "@/nav"
const ALT_ZU_PFAD: Record<ViewId, string> = Object.fromEntries(
NAV.map((n) => [n.id, n.pfad]),
) as Record<ViewId, string>
export function hashUmleiten(): void {
const hash = window.location.hash.replace(/^#\/?/, "")
if (!hash) return
const ziel = ALT_ZU_PFAD[hash as ViewId]
if (!ziel) return
window.history.replaceState(null, "", ziel + window.location.search)
}
-71
View File
@@ -1,71 +0,0 @@
import { cn } from "@/lib/utils"
// Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX,
// bewusst ohne externe Lib). Überschriften, Listen, fett/kursiv, `code`, Codeblöcke.
// Verwandt mit dem wiki-link-Renderer in WissenView, hier aber ohne [[links]] —
// gedacht für Doku-Anzeige (z. B. das KONZEPT.md eines vorbereiteten IDE-Projekts).
export function Markdown({ text, className }: { text: string; className?: string }) {
const lines = (text || "").split("\n")
const out: React.ReactNode[] = []
let list: React.ReactNode[] = []
let code: string[] | null = null
const flushList = (key: string) => {
if (list.length) {
out.push(<ul key={key} className="mb-3 ml-4 list-disc space-y-1">{list}</ul>)
list = []
}
}
lines.forEach((raw, i) => {
const key = `l${i}`
if (code !== null) {
if (raw.trimEnd() === "```") {
out.push(<pre key={key} className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85">{code.join("\n")}</pre>)
code = null
} else code.push(raw)
return
}
if (raw.trimStart().startsWith("```")) { flushList(key); code = []; return }
const line = raw.trimEnd()
if (!line.trim()) { flushList(key); return }
const h = line.match(/^(#{1,4})\s+(.*)$/)
if (h) {
flushList(key)
const level = h[1].length
const cls = level === 1 ? "text-base font-bold mt-1 mb-2" : level === 2 ? "text-sm font-bold mt-4 mb-1.5" : "text-xs font-bold mt-3 mb-1 uppercase tracking-wide text-muted-foreground"
out.push(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], key)}</p>)
return
}
const li = line.match(/^\s*[-*•]\s+(.*)$/)
if (li) {
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(li[1], key)}</li>)
return
}
const ol = line.match(/^\s*\d+\.\s+(.*)$/)
if (ol) {
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(ol[1], key)}</li>)
return
}
flushList(key)
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, key)}</p>)
})
flushList("end")
if (code !== null) out.push(<pre key="code-end" className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]">{(code as string[]).join("\n")}</pre>)
return <div className={cn("max-w-3xl", className)}>{out}</div>
}
// Inline: **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
function inline(text: string, keyBase: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g)
return parts.map((p, i) => {
const key = `${keyBase}-${i}`
if (/^\*\*[^*]+\*\*$/.test(p)) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
if (/^\*[^*]+\*$/.test(p)) return <i key={key}>{p.slice(1, -1)}</i>
if (/^`[^`]+`$/.test(p)) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-foreground/90">{p.slice(1, -1)}</code>
return <span key={key}>{p}</span>
})
}
+40
View File
@@ -0,0 +1,40 @@
// Kurze Rückmeldungen nach einer Aktion („Dienst neu gestartet“). Außerhalb von React
// gehalten, damit jede Stelle melden kann, ohne einen Kontext durchzureichen.
import { useSyncExternalStore } from "react"
export type MeldungsArt = "erfolg" | "fehler" | "info"
export interface Meldung {
id: number
art: MeldungsArt
text: string
}
let meldungen: Meldung[] = []
let naechste = 1
const hoerer = new Set<() => void>()
const melde = () => hoerer.forEach((h) => h())
export function melden(art: MeldungsArt, text: string) {
const id = naechste++
meldungen = [...meldungen, { id, art, text }].slice(-4)
melde()
// Fehler bleiben, bis man sie wegklickt — die will man lesen können.
if (art !== "fehler") window.setTimeout(() => entfernen(id), 6000)
}
export function entfernen(id: number) {
meldungen = meldungen.filter((m) => m.id !== id)
melde()
}
export const useMeldungen = () =>
useSyncExternalStore(
(h) => {
hoerer.add(h)
return () => {
hoerer.delete(h)
}
},
() => meldungen,
)
-120
View File
@@ -1,120 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { act, renderHook } from "@testing-library/react"
import type { MetrikPunkt } from "./events"
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel überleben). Für jeden Test
// eine frische Modul-Instanz, sonst tragen sich die Fälle gegenseitig Daten ein.
async function frischerStore() {
vi.resetModules()
return await import("./metricsStore")
}
function punkt(p: Partial<MetrikPunkt> = {}): MetrikPunkt {
return {
cpu: 10, ram: 20, ram_used: 1, ram_total: 2, gpu: null, disk: null,
temp_cpu: null, temp_gpu: null, uptime_s: 100, tok_p: 0, tok_c: 0, ...p,
}
}
describe("metricsStore", () => {
let store: Awaited<ReturnType<typeof frischerStore>>
beforeEach(async () => {
store = await frischerStore()
})
it("beginnt leer", () => {
const { result } = renderHook(() => store.useSysHistory())
expect(result.current).toEqual([])
})
it("meldet neue Messpunkte an die Abonnenten", () => {
const { result } = renderHook(() => store.useSysHistory())
act(() => store.pushSys({ t: 1, cpu: 10, ram: 20, gpu: 30, disk: 40 }))
expect(result.current).toHaveLength(1)
expect(result.current[0].cpu).toBe(10)
})
// Ohne Deckel wächst der Verlauf unbegrenzt — bei 1-s-Takt sind das ~86 400 Punkte
// pro Tag in einem Dauerläufer-Tab.
it("deckelt den Verlauf und behält die JÜNGSTEN Punkte", () => {
const { result } = renderHook(() => store.useSysHistory())
const n = store.MAX_POINTS + 20
act(() => {
for (let i = 0; i < n; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
})
expect(result.current).toHaveLength(store.MAX_POINTS)
expect(result.current[0].t).toBe(n - store.MAX_POINTS)
expect(result.current[store.MAX_POINTS - 1].t).toBe(n - 1)
})
it("hält System- und Token-Verlauf auseinander", () => {
const { result: sys } = renderHook(() => store.useSysHistory())
const { result: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
expect(sys.current).toHaveLength(0)
expect(tok.current).toHaveLength(1)
expect(tok.current[0].completion).toBe(7)
})
// useSyncExternalStore vergleicht per Referenz: würde pushSys das Array an Ort und
// Stelle verändern, bliebe die Anzeige stehen, obwohl Daten da sind.
it("liefert bei jeder Änderung ein NEUES Array (Referenzwechsel)", () => {
const { result } = renderHook(() => store.useSysHistory())
const vorher = result.current
act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null }))
expect(result.current).not.toBe(vorher)
})
})
// ── Der Einstieg des Ereignisstroms (v3-Umbau P4) ───────────────────────────
// Der Server schickt Token-GESAMTZÄHLER, keine Raten — die Umrechnung passiert hier.
// Genau dort steckt die Fehleranfälligkeit: ein Backend-Neustart setzt die Zähler
// zurück, und aus einem Rückwärtssprung darf keine negative Rate werden.
describe("pushMetrik (Strom-Einstieg)", () => {
let store: Awaited<ReturnType<typeof frischerStore>>
beforeEach(async () => {
store = await frischerStore()
vi.useFakeTimers()
})
it("schreibt beim ersten Punkt NOCH KEINE Rate — dafür fehlt der Vorgänger", () => {
const { result: sys } = renderHook(() => store.useSysHistory())
const { result: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
expect(sys.current).toHaveLength(1)
expect(tok.current).toHaveLength(0)
})
it("rechnet aus zwei Zählerständen eine Rate pro Sekunde", () => {
const { result } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
act(() => {
vi.advanceTimersByTime(2_000)
store.pushMetrik(punkt({ tok_p: 300, tok_c: 90 }))
})
expect(result.current).toHaveLength(1)
expect(result.current[0].prompt).toBeCloseTo(100, 1) // 200 Token in 2 s
expect(result.current[0].completion).toBeCloseTo(20, 1) // 40 Token in 2 s
})
it("macht aus einem Zähler-Rücksprung keine negative Rate", () => {
const { result } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 5_000, tok_c: 5_000 })))
act(() => {
vi.advanceTimersByTime(1_000)
store.pushMetrik(punkt({ tok_p: 0, tok_c: 0 })) // Backend neu gestartet
})
expect(result.current[0].prompt).toBe(0)
expect(result.current[0].completion).toBe(0)
})
it("hält den letzten Messpunkt für die Statusleiste bereit", () => {
const { result } = renderHook(() => store.useLetzteMetrik())
expect(result.current).toBeNull()
act(() => store.pushMetrik(punkt({ uptime_s: 4_242, temp_cpu: 71 })))
expect(result.current?.uptime_s).toBe(4_242)
expect(result.current?.temp_cpu).toBe(71)
})
})
-114
View File
@@ -1,114 +0,0 @@
import { useEffect, useRef, useSyncExternalStore } from "react"
import { useSystemStatus, useTokenStats } from "@/lib/queries"
import type { MetrikPunkt } from "@/lib/events"
export type SysSample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
export type RateSample = { t: number; prompt: number; completion: number }
// v3-Umbau P4: Der Strom liefert jede Sekunde einen Punkt statt alle drei. Bei gleicher
// Fenstergröße (~2 Min Live-Verlauf) heißt das dreimal so viele Punkte.
// Exportiert, damit der Test gegen DIESEN Wert prüft und nicht gegen eine Kopie, die
// beim nächsten Takt-Wechsel still danebenliegt.
export const MAX_POINTS = 120
// Modul-globaler Verlauf: überlebt Mount/Unmount (Tab-Wechsel) → Graphen starten nie leer.
//
// Bewusst KEIN Zustand-Store (v3-Umbau P3/P4): Hier landet jede Sekunde ein Wert. Ein
// Store-Update pro Sekunde würde jede abonnierende Komponente neu rendern; mit einem
// eigenen `useSyncExternalStore` außerhalb von React rendern nur die Karten neu, die den
// Verlauf wirklich lesen. Das ist der Grund, warum diese Datei nicht in app/store.ts
// aufgegangen ist.
let sysHist: SysSample[] = []
let tokHist: RateSample[] = []
const listeners = new Set<() => void>()
const emit = () => listeners.forEach((l) => l())
function subscribe(l: () => void) {
listeners.add(l)
return () => { listeners.delete(l) }
}
export function pushSys(s: SysSample) { sysHist = [...sysHist, s].slice(-MAX_POINTS); emit() }
export function pushTok(s: RateSample) { tokHist = [...tokHist, s].slice(-MAX_POINTS); emit() }
// Der ZULETZT empfangene Messpunkt. Warum eigens gehalten: Seit P4 ist der Poller von
// /api/system/status abgeschaltet, solange der Strom steht — die Query liefert dann nur
// noch den Stand vom Seitenaufbau. Ohne diesen Wert froeren Betriebszeit, Temperatur und
// die aktuellen Prozentzahlen bei der ersten gemessenen Sekunde ein.
let letzteMetrik: MetrikPunkt | null = null
export const useSysHistory = () => useSyncExternalStore(subscribe, () => sysHist)
export const useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist)
export const useLetzteMetrik = () => useSyncExternalStore(subscribe, () => letzteMetrik)
// Letzter Token-Gesamtstand, um daraus die Rate zu bilden. Der Server schickt bewusst
// Zähler statt Raten — so ist er zustandslos und ein verpasster Punkt verfälscht nichts.
let letzteToken: { p: number; c: number; t: number } | null = null
/** Einstieg für den Ereignisstrom (lib/events.ts). Ein Messpunkt → beide Verläufe. */
export function pushMetrik(m: MetrikPunkt) {
const jetzt = Date.now()
letzteMetrik = m
pushSys({ t: jetzt, cpu: m.cpu ?? 0, ram: m.ram ?? 0, gpu: m.gpu, disk: m.disk })
if (letzteToken) {
const dt = Math.max((jetzt - letzteToken.t) / 1000, 0.001)
// Nie negativ: Startet das Backend neu, springen die Zähler zurück — daraus eine
// negative Rate zu zeichnen wäre schlicht falsch.
pushTok({
t: jetzt,
prompt: Math.max(0, (m.tok_p - letzteToken.p) / dt),
completion: Math.max(0, (m.tok_c - letzteToken.c) / dt),
})
}
letzteToken = { p: m.tok_p, c: m.tok_c, t: jetzt }
}
/** Sammler für den Rückfall-Betrieb (in der Shell eingehängt, also stets gemountet).
*
* Seit P4 kommen die Messpunkte über den Strom. Dieser Sammler füttert den Verlauf nur
* noch aus den POLLERN — also genau dann, wenn der Strom gerissen ist und die Queries
* wieder im Normaltakt nachfragen. Ohne ihn stünden die Graphen bei einem Ausfall still,
* obwohl die Daten da wären.
*
* Doppelte Punkte kann es nicht geben: Steht der Strom, sind die Poller entspannt und
* liefern kaum neue Zeitstempel; der Filter unten wirft alles weg, was jünger als eine
* Sekunde nach dem letzten Punkt ist.
*/
export function useMetricsFeeder() {
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus()
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats()
const prevTok = useRef<{ p: number; c: number; t: number } | null>(null)
useEffect(() => {
if (!sys) return
const jetzt = Date.now()
if (sysHist.length && jetzt - sysHist[sysHist.length - 1].t < 1_000) return
pushSys({
t: jetzt,
cpu: sys.cpu?.percent ?? 0,
ram: sys.ram?.percent ?? 0,
gpu: sys.gpu?.busy_percent ?? null,
disk: sys.disk?.percent ?? null,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sysAt])
useEffect(() => {
if (!ts) return
const now = Date.now()
const p = ts.prompt_tokens, c = ts.completion_tokens
if (prevTok.current) {
const dt = Math.max((now - prevTok.current.t) / 1000, 0.001)
if (!tokHist.length || now - tokHist[tokHist.length - 1].t >= 1_000) {
pushTok({
t: now,
prompt: Math.max(0, (p - prevTok.current.p) / dt),
completion: Math.max(0, (c - prevTok.current.c) / dt),
})
}
}
prevTok.current = { p, c, t: now }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tsAt])
}
-262
View File
@@ -1,262 +0,0 @@
// Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
import { sseVerbunden } from "./events"
import {
api,
type AgentStatus,
type AktivitaetResp,
type ChronikResp,
type ConnectResp,
type ConnectHealth,
type DiscoverResp,
type DraftsResp,
type GroupsResp,
type HermesBrainResp,
type Health,
type HistoryResp,
type IdeenLogResp,
type IdeenResp,
type Job,
type MemoryGraph,
type ModelsResp,
type Reminder,
type RoutingResp,
type RoutingPolicyMeta,
type ServicesResp,
type SystemStatus,
type TokenStats,
type UpdatesResp,
type VoiceTraceResp,
type WissenResp,
type ZeitmaschineResp,
} from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
export const qk = {
health: ["health"] as const,
systemStatus: ["system-status"] as const,
services: ["services"] as const,
models: ["models"] as const,
groups: ["groups"] as const,
routing: ["routing"] as const,
routingPolicy: ["routing-policy"] as const,
jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const,
agentStatus: ["agent-status"] as const,
agentAktivitaet: ["agent-aktivitaet"] as const,
hermesBrain: ["hermes-brain"] as const,
updates: ["updates"] as const,
discover: ["discover"] as const,
drafts: (target?: string) => ["drafts", target ?? ""] as const,
connect: (params?: string) => ["connect", params ?? ""] as const,
connectHealth: ["connect-health"] as const,
memoryGraph: ["memory-graph"] as const,
voiceTrace: ["voice-trace"] as const,
ideen: ["ideen"] as const,
chronik: ["chronik"] as const,
wissen: ["wissen"] as const,
zeitmaschine: ["zeitmaschine"] as const,
reminders: ["reminders"] as const,
}
// ── Polling-Takte ────────────────────────────────────────────────────────────
// EINE Stelle für alle Intervalle. Regel: Die Live-Graphen (System/Token) takten
// schnell, alles andere gemächlich — Mutationen invalidieren ohnehin gezielt,
// Polling ist nur das Sicherheitsnetz. Komponenten übergeben KEINE eigenen
// Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt).
const TAKT = {
graph: 3_000, // System + Token: seit P4 nur noch Rueckfall/Randdaten (siehe unten)
schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace
normal: 10_000, // Dienste, Routing, Agent-Status, Health
gemuetlich: 30_000, // Chronik, Ideen
traege: 60_000, // Updates-Check, Reminders, Zeitmaschine, HF-Abfragen
} as const
// UMBAU v3 P3a: Läuft der SSE-Eventstrom (/api/events, siehe lib/events.ts), sind die
// Poller der ereignishaften Quellen nur noch SICHERHEITSNETZ — der Strom invalidiert
// bei Änderung sofort. Verbunden = Takt ×5; Strom weg = alter Takt (EventSource
// reconnectet selbst). Live-Graphen/Status bleiben bewusst beim Polling.
const relax = (ms: number) => () => (sseVerbunden() ? ms * 5 : ms)
// UMBAU v3 P4: Die MESSWERTE (CPU, RAM, GPU, Temperatur, Token-Zähler, Betriebszeit)
// kommen jetzt gepusht — Ereignis `metrik`, jede Sekunde. Der Poller dieser beiden
// Endpunkte war die halbe Grundlast der Startseite (Befund B-12).
//
// Er wird trotzdem NICHT abgeschaltet, sondern gedrosselt. Grund: Beide Antworten tragen
// mehr als die Messwerte — `/api/system/status` liefert die Versions-Hashes (Sidebar-Fuß),
// `/api/system/token-stats` die Gesamtsumme und die Cloud-Ersparnis, für die das Backend
// die Tarife auflöst (Preis-Logik ist dort die einzige Wahrheit, sie hier nachzubauen wäre
// eine zweite). Beides ändert sich langsam — einmal pro Minute reicht dafür völlig.
//
// 3 s → 60 s ist eine Zwanzigstel-Last; abschalten hätte diese Felder einfrieren lassen.
const langsamWennStrom = (ms: number) => () => (sseVerbunden() ? 60_000 : ms)
// Ideen-Queue (natives Hermes-Kanban): das Backend cached ~15 s, öfter pollen lohnt nicht.
export const useIdeen = (refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: qk.ideen,
queryFn: () => api<IdeenResp>("/api/ideen"),
refetchInterval: relax(refetchInterval),
})
// Ideen-Log (Live Worker Log): pollt nur solange Idee triage/running UND offen
export const useIdeenLog = (id: string, enabled: boolean = false) =>
useQuery({
queryKey: ["ideen-log", id],
queryFn: () => api<IdeenLogResp>(`/api/ideen/${id}/log`),
enabled,
refetchInterval: 4000, // alle ~4s
staleTime: 0,
})
// Limit gehört in den Key: zwei Aufrufer mit verschiedenen Limits würden sich sonst
// denselben Cache-Eintrag gegenseitig überschreiben. SSE invalidiert das Präfix ["chronik"].
export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: [...qk.chronik, limit],
queryFn: () => api<ChronikResp>(`/api/chronik?limit=${limit}`),
refetchInterval: relax(refetchInterval),
select: (d) => d.items ?? [],
})
export const useWissen = () =>
useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") })
export const useZeitmaschine = (refetchInterval: number = TAKT.traege, enabled = true) =>
useQuery({
queryKey: qk.zeitmaschine,
queryFn: () => api<ZeitmaschineResp>("/api/zeitmaschine"),
refetchInterval,
enabled,
})
export const useReminders = (refetchInterval: number = TAKT.traege) =>
useQuery({
queryKey: qk.reminders,
queryFn: () => api<{ items: Reminder[] }>("/api/reminders"),
refetchInterval: relax(refetchInterval),
select: (d) => d.items ?? [],
})
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval: number = TAKT.schnell) =>
useQuery({
queryKey: qk.voiceTrace,
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
refetchInterval,
select: (d) => d.turns ?? [],
})
export const useMemoryGraph = (enabled = true) =>
useQuery({
queryKey: qk.memoryGraph,
queryFn: () => api<MemoryGraph>("/api/wissen/graph"),
enabled,
staleTime: 60 * 1000,
})
export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal })
export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"),
refetchInterval: langsamWennStrom(refetchInterval) })
// Metrik-Verlauf (Cockpit-Zeitachse): 1h/24h aus dem Backend-Ringpuffer. Nur aktiv,
// wenn eine Karte gerade diesen Bereich zeigt (enabled) — Live bleibt beim 3s-Store.
export const useMetricHistory = (minutes: number, enabled = true) =>
useQuery({
queryKey: ["metrics-history", minutes],
queryFn: () => api<HistoryResp>(`/api/system/history?minutes=${minutes}`),
enabled,
refetchInterval: 60_000,
})
export const useServices = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
// Der SSE-Strom invalidiert "models" bei Laden/Entladen → Poller ist nur Sicherheitsnetz.
export const useModels = (refetchInterval: number = TAKT.schnell) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval: relax(refetchInterval) })
export const useGroups = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
export const useRouting = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
export const useRoutingPolicy = () =>
useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") })
// Jobs (Downloads) takten etwas schneller, damit Fortschritts-Balken flüssig wirken.
// `enabled=false` pausiert den Beobachter (z. B. geschlossene System-Schublade) —
// der Cache bleibt geteilt, es pollt nur niemand umsonst.
export const useJobs = (refetchInterval = 3_000, enabled = true) =>
useQuery({
queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
// Seit P4 meldet der Fingerabdruck-Sammler Zustand UND Fortschritt der Jobs —
// der Poller ist damit auch hier nur noch Sicherheitsnetz.
refetchInterval: relax(refetchInterval),
enabled,
select: (d) => d.jobs ?? [],
})
export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"),
refetchInterval: langsamWennStrom(refetchInterval) })
export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
// Werkzeug-Verlauf des Agenten. Liest das Ende von Hermes' Log — billig, aber es
// aendert sich nur, wenn Lucy wirklich arbeitet; der gemuetliche Takt reicht.
export const useWerkzeugverlauf = (refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: qk.agentAktivitaet,
queryFn: () => api<AktivitaetResp>("/api/agent/aktivitaet?limit=80"),
refetchInterval: relax(refetchInterval),
})
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval: number = TAKT.traege) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
// Updates-Check ist teuer (apt/git auf der Box) — träge pollen reicht völlig.
export const useUpdates = (refetchInterval: number = TAKT.traege) =>
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
export const useDiscover = () =>
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
// Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad).
export const useDrafts = (target?: string) =>
useQuery({
queryKey: qk.drafts(target),
queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`),
enabled: !!target,
})
export const useConnect = (params?: string) =>
useQuery({
queryKey: qk.connect(params),
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
})
// Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert.
export const useConnectHealth = () =>
useQuery({
queryKey: qk.connectHealth,
queryFn: () => api<ConnectHealth>("/api/connect/health"),
refetchInterval: 15000,
})
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */
export function invalidate(qc: QueryClient, ...keys: (readonly unknown[])[]) {
for (const key of keys) qc.invalidateQueries({ queryKey: key })
}
export { useQueryClient }
-145
View File
@@ -1,145 +0,0 @@
import {
Brain,
BrainCog,
Code,
Eye,
BookMarked,
Telescope,
Scale,
ListOrdered,
type LucideIcon,
} from "lucide-react"
// ── EINE Quelle der Wahrheit für ALLE Serving-Rollen ────────────────────────
// Klartext statt Kürzel: jede Rolle bekommt einen Zweck-Namen, ein Icon und
// eine Ein-Satz-Erklärung, damit die UI ohne Fachjargon verständlich ist.
// Das technische Kürzel (`role`) bleibt für den späteren Experten-Modus erhalten.
//
// Die Rollen hier spiegeln den ECHTEN Stand der Box (/api/models → role):
// hermes · heavy · coder · coder-lite · vision · embed · scout
// (Kein Phantom-`fast`: das ist nur ein Lane-Alias, keine Modell-Rolle. Lucys
// Alltags-Hirn ist die Rolle `hermes` = Qwen3.6.)
export interface RoleMeta {
role: string // technisches Kürzel (Experten-Modus)
label: string // Klartext-Zweckname
short: string // Kurzform für enge Badges
icon: LucideIcon
desc: string // Tooltip / Erklärung in einfacher Sprache
tone: string // Farbklassen fürs Badge
protected?: boolean // Hirn & Gedächtnis: lebenswichtig → nicht löschbar/entladbar (Etappe 3)
}
// Reihenfolge = Anzeige-Reihenfolge in den Slot-Übersichten (Wichtigstes zuerst).
export const ROLE_META: RoleMeta[] = [
{
role: "hermes",
label: "Lucys Hirn",
short: "Hirn",
icon: Brain,
desc: "Lucys Alltags-Verstand — denkt und redet mit dir. Immer bereit.",
tone: "bg-indigo-500/15 text-indigo-400 border-indigo-500/25",
protected: true,
},
{
role: "embed",
label: "Lucys Gedächtnis",
short: "Gedächtnis",
icon: BookMarked,
desc: "Merkt sich Wichtiges und findet es später wieder.",
tone: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
protected: true,
},
{
role: "reranker",
label: "Gedächtnis-Sortierer",
short: "Sortierer",
icon: ListOrdered,
desc: "Ordnet gefundene Erinnerungen nach echter Relevanz — die Feinsortierung hinter dem Gedächtnis.",
tone: "bg-lime-500/15 text-lime-400 border-lime-500/25",
},
{
role: "vision",
label: "Lucys Augen",
short: "Augen",
icon: Eye,
desc: "Sieht Bilder und Screenshots und beschreibt, was drauf ist.",
tone: "bg-pink-500/15 text-pink-400 border-pink-500/25",
},
{
role: "heavy",
label: "Großes Hirn",
short: "Groß",
icon: BrainCog,
desc: "Das schwere Geschütz — wird für besonders knifflige Fragen dazugeholt.",
tone: "bg-amber-500/15 text-amber-400 border-amber-500/25",
},
{
role: "coder-lite",
label: "Programmierer (schnell)",
short: "Code schnell",
icon: Code,
desc: "Schnelles Programmieren im Alltag.",
tone: "bg-violet-500/15 text-violet-400 border-violet-500/25",
},
{
role: "coder",
label: "Programmierer",
short: "Code",
icon: Code,
desc: "Baut und plant. Der Kopf der Coding-Mannschaft — er hält den Faden und verteilt Zuarbeit.",
tone: "bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/25",
protected: true,
},
{
role: "scout",
label: "Späher",
short: "Späher",
icon: Telescope,
desc: "Schneller erster Blick und grobe Einschätzung.",
tone: "bg-teal-500/15 text-teal-400 border-teal-500/25",
},
{
role: "kritiker",
label: "Kritiker",
short: "Kritiker",
icon: Scale,
desc: "Liest dem Programmierer nach jeder Etappe gegen — bewusst aus einer FREMDEN Modellfamilie (Mistral statt Qwen), damit er andere blinde Flecken hat.",
tone: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25",
},
]
// Kanonische Rollen-Reihenfolge (ersetzt die alte, veraltete ROLES-Liste).
export const ROLES: string[] = ROLE_META.map((m) => m.role)
const BY_ROLE: Record<string, RoleMeta> = Object.fromEntries(
ROLE_META.map((m) => [m.role, m]),
)
// Fallback für unbekannte/neue Rollen — nie „undefined", damit die UID nicht bricht.
const UNKNOWN = (role?: string | null): RoleMeta => ({
role: role || "?",
label: role || "unbekannt",
short: role || "?",
icon: Brain,
desc: "Rolle ohne hinterlegten Klartext-Namen.",
tone: "bg-slate-500/15 text-slate-300 border-slate-500/25",
})
// Aliase, die auf eine echte Rolle zeigen (z.B. Lane-Alias `fast` = Hirn).
const ALIAS: Record<string, string> = {
fast: "hermes",
coder_lite: "coder-lite",
}
export function roleMeta(role?: string | null): RoleMeta {
if (!role) return UNKNOWN(role)
return BY_ROLE[role] || BY_ROLE[ALIAS[role]] || UNKNOWN(role)
}
// Klartext-Name einer Rolle (mit optionaler Kurzform).
export const roleLabel = (role?: string | null, short = false) =>
short ? roleMeta(role).short : roleMeta(role).label
// Farbton — bleibt kompatibel zur bisherigen roleTone()-Signatur.
export const roleTone = (role?: string | null) => roleMeta(role).tone
+89
View File
@@ -0,0 +1,89 @@
// Live-Strom — Gegenstück zu backend/routers/events.py (/api/stream).
//
// Ein EventSource, zwei Ereignisarten:
// · `invalidate` stößt die betroffenen Abfragen an (z. B. „start“, wenn der Wächter
// etwas Neues findet — sein Stand ändert sich höchstens einmal pro Minute)
// · `metrik` jede Sekunde ein Messpunkt für die Rundinstrumente
//
// Der Messpunkt liegt bewusst AUSSERHALB von React (useSyncExternalStore): So zeichnen
// nur die Instrumente jede Sekunde neu, nicht die ganze Seite.
import { useEffect, useSyncExternalStore } from "react"
import { useQueryClient } from "@tanstack/react-query"
export interface MetrikPunkt {
cpu: number
ram: number
ram_used: number
ram_total: number
gpu: number | null
disk: number | null
temp_cpu: number | null
temp_gpu: number | null
uptime_s: number | null
tok_p: number
tok_c: number
}
export type StromLage = "verbinde" | "live" | "getrennt"
let lage: StromLage = "verbinde"
let letzte: MetrikPunkt | null = null
const hoerer = new Set<() => void>()
const melde = () => hoerer.forEach((h) => h())
function abonniere(h: () => void) {
hoerer.add(h)
return () => {
hoerer.delete(h)
}
}
export const stromVerbunden = () => lage === "live"
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
export const useLetzteMetrik = () => useSyncExternalStore(abonniere, () => letzte)
/** Nur für Tests: Messpunkt direkt einspeisen. */
export function metrikEinspeisen(m: MetrikPunkt | null) {
letzte = m
melde()
}
function setzeLage(neu: StromLage) {
if (neu !== lage) {
lage = neu
melde()
}
}
export function useStrom() {
const qc = useQueryClient()
useEffect(() => {
const es = new EventSource("/api/stream")
es.onopen = () => {
setzeLage("live")
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
qc.invalidateQueries()
}
es.onerror = () => setzeLage("getrennt")
es.addEventListener("invalidate", (e) => {
try {
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
} catch {
/* ein kaputtes Ereignis reißt den Strom nicht */
}
})
es.addEventListener("metrik", (e) => {
try {
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
} catch {
/* ein kaputter Messpunkt reißt den Strom nicht */
}
})
return () => {
es.close()
setzeLage("getrennt")
}
}, [qc])
}
+153
View File
@@ -0,0 +1,153 @@
// Datenformen der Box-Wart-Schnittstellen — Gegenstück zu backend/routers/boxwart.py,
// services/waechter.py, modell_nutzung.py, zeitplan.py und routers/radar.py.
export type Stufe = "rot" | "gelb"
export type LampenZustand = "ok" | "aus" | "warn" | "info" | "fehler"
export interface HinweisAktion {
id: "neustart" | "protokoll" | "job-wiederholen" | string
label: string
dienst?: string
job?: string
}
export interface Hinweis {
id: string
stufe: Stufe
titel: string
text: string
quelle: string
seit: number
zuletzt: number
takte?: number
aktionen: HinweisAktion[]
}
export interface VerlaufEintrag {
ts: number
art: "neu" | "erledigt" | "auto"
id: string
text: string
}
export interface Lampe {
id: string
label: string
zustand: LampenZustand
wert: string
}
export interface FlugplanEintrag {
zeit: string
titel: string
text: string
status: "erledigt" | "fehler" | "geplant"
}
export interface Start {
zustand: {
stufe: "ok" | Stufe
anzahl: number
waechter_wach: boolean
stand: number | null
update_laeuft: boolean
}
hinweise: Hinweis[]
verlauf: VerlaufEintrag[]
lampen: Lampe[]
box: {
ram_used: number | null
ram_total: number | null
temp_cpu: number | null
temp_gpu: number | null
platte: { used: number; total: number; percent: number } | null
uptime_s: number | null
}
updates: {
bausteine: { id: string; name: string; neu: string }[]
modelle: { role: string; title: string; repo: string }[]
}
flugplan: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }
}
export interface AktionsErgebnis {
ok: boolean
text?: string
out?: string
err?: string
detail?: string
}
export interface Nutzung {
tage: number
absender: { name: string; anfragen: number }[]
je_tag: Record<string, Record<string, number>>
letzte_24h: { stunde: string; je_absender: Record<string, number> }[]
zuletzt_geladen: Record<string, string>
}
export interface Modell {
name: string
role: string | null
aliases: string[]
ctx: number | null
ttl: number | null
filename: string
quant: string
size_bytes: number | null
spec_active: boolean
parallel_slots: number
capabilities?: { vision?: boolean; tools?: boolean; params_b?: number } & Record<string, unknown>
}
export interface Job {
id: string
label: string
state: "running" | "done" | "failed" | "cancelled" | string
progress?: number | null
log?: string[]
started?: number
finished?: number | null
}
export interface Sicherung {
snapshot: string
file: string
size_mb: number
mtime?: number
}
export type RadarStatus =
| "neu" | "wartet" | "getestet" | "bestanden" | "durchgefallen" | "uebernommen" | "verworfen"
export interface RadarKandidat {
id: string
name: string
rolle: "hirn" | "coder" | string
repo: string
datei: string
groesse_gb: number | null
passt: boolean
eng: boolean
quelle: string
status: RadarStatus
begruendung: string
}
export interface RadarTest {
id: string
kandidat: string
rolle: string
datum: string
ergebnis: "bestanden" | "durchgefallen" | "abgebrochen"
werte: Record<string, unknown>
vergleich: Record<string, unknown>
zusammenfassung: string
}
export interface Radar {
naechster_test: string | null
letzter_test: string | null
kandidaten: RadarKandidat[]
tests: RadarTest[]
}
-59
View File
@@ -1,59 +0,0 @@
// Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View
// duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State.
//
// Nutzung:
// const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
// ...
// showConfirm("Titel", "Wirklich?", () => doIt())
// return (<>{dialogElement}...</>)
import { useCallback, useState } from "react"
import { CustomDialog } from "@/components/CustomDialog"
interface DialogState {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
autoValue?: string
autoLabel?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function useDialog() {
const [dialog, setDialog] = useState<DialogState | null>(null)
const close = useCallback(() => setDialog(null), [])
const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => {
setDialog({
type: "alert", title, message,
onConfirm: () => { setDialog(null); onConfirm?.() },
})
}, [])
const showConfirm = useCallback(
(title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
setDialog({
type: "confirm", title, message,
onConfirm: () => { setDialog(null); onConfirm() },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const showPrompt = useCallback(
(title: string, message: string, defaultValue: string,
onConfirm: (val?: string) => void, onCancel?: () => void,
opts?: { autoValue?: string; autoLabel?: string }) => {
setDialog({
type: "prompt", title, message, defaultValue,
autoValue: opts?.autoValue, autoLabel: opts?.autoLabel,
onConfirm: (val) => { setDialog(null); onConfirm(val) },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const dialogElement = dialog ? <CustomDialog {...dialog} /> : null
return { showAlert, showConfirm, showPrompt, close, dialogElement }
}
-235
View File
@@ -1,235 +0,0 @@
// „Geht's Lucy gut?" — bündelt die vielen technischen Status-Signale zu wenigen
// Lucy-Fähigkeiten in Klartext, inkl. Gesamt-Verdikt, Speicher-Ampel und einer
// konkreten 1-Klick-Reparatur pro Problem. EINE Quelle für das Status-Panel.
import { Brain, BookMarked, Eye, Plug, Bot, Mic, ShieldAlert, Server, type LucideIcon } from "lucide-react"
import { useHealth, useServices, useModels, useSystemStatus } from "./queries"
export type CheckStatus = "ok" | "warn" | "down" | "loading"
// Reparatur-Rezept: entweder einen systemd-Dienst neu starten oder ein Modell laden.
// WICHTIG: `service` sind die ECHTEN Unit-Namen (nicht die Anzeige-Namen der
// Dienste-Karte), damit /api/maintenance/restart sie akzeptiert.
export type Repair =
| { kind: "restart"; service: string; label: string; needsSudo?: boolean }
| { kind: "loadModel"; model: string; label: string }
export interface LucyCheck {
id: string
label: string // Klartext-Name der Fähigkeit
icon: LucideIcon
status: CheckStatus
detail: string // Ein Satz: was ist gerade los
critical: boolean // zählt fürs Gesamt-Verdikt „Problem"
repair?: Repair
}
export type Verdict = "loading" | "gut" | "warn" | "problem"
export interface MemoryAmpel {
status: "ok" | "warn" | "full" | "unknown"
usedGb: number
totalGb: number
pct: number
text: string
}
export interface LucyHealth {
verdict: Verdict
checks: LucyCheck[]
memory: MemoryAmpel
problems: LucyCheck[] // kritische Down-Checks (für den Schnellzugriff im Banner)
warns: LucyCheck[] // nicht-kritische Hinweise — damit das Banner SAGT, was los ist
unreachable: boolean // Zentrale (MC2) antwortet nicht → alle anderen Anzeigen sind veraltet
}
export function useLucyHealth(): LucyHealth {
const { data: health, isError: healthErr } = useHealth()
const { data: svc, isError: svcErr } = useServices()
const { data: modelsResp } = useModels()
const { data: sys } = useSystemStatus()
// React Query behält bei Fehlern die letzten Daten — ohne diesen Riegel zeigte die UI
// bei totem Backend dauerhaft den letzten grünen Stand („eingefrorene Ampel", Review 15.07.).
const unreachable = healthErr || svcErr
const loaded = !!health && !!svc
// Matching über den ECHTEN systemd-Unit-Namen (Backend liefert `unit`) — vorher wurde
// fragil im deutschen Anzeige-Namen gesucht ("gateway (integr" …).
const svcOk = (unit: string): boolean | undefined =>
svc?.services.find((s) => s.unit === unit)?.ok
const svcHas = (unit: string): boolean => !!svc?.services.some((s) => s.unit === unit)
const engineOk = health?.engine_reachable
const brainReady = health?.brain?.ready
const brainName =
health?.brain?.model ||
modelsResp?.models.find((m) => m.role === "hermes")?.name ||
""
const checks: LucyCheck[] = []
// 0) Die Zentrale selbst. Antwortet MC2 nicht, ist ALLES andere Spekulation —
// dieser Check übertrumpft die übrigen (die zeigen sonst eingefrorene alte Werte).
if (unreachable) {
checks.push({
id: "mc2", label: "Steuerpult (MC2)", icon: Server, critical: true, status: "down",
detail: "Die Zentrale antwortet nicht — alle Anzeigen hier können veraltet sein. Läuft die Box? Deploy aktiv?",
})
}
// 1) Lucys Hirn — die wichtigste Fähigkeit. Engine läuft UND das Hirn-Modell ist geladen?
checks.push(((): LucyCheck => {
const base = { id: "brain", label: "Lucys Hirn", icon: Brain, critical: true }
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
if (engineOk === false)
return {
...base,
status: "down",
detail: "Die Motor-Maschine (Engine) läuft nicht — Lucy kann gerade gar nicht denken.",
repair: { kind: "restart", service: "llama-swap", label: "Motor neu starten", needsSudo: true },
}
if (brainReady === false)
return {
...base,
status: "down",
detail: "Lucys Hirn ist gerade eingeschlafen — einmal aufwecken.",
repair: brainName
? { kind: "loadModel", model: brainName, label: "Hirn aufwecken" }
: undefined,
}
return { ...base, status: "ok", detail: "Wach und ansprechbar." }
})())
// 2) Lucys Augen — das Vision-Modell (Rolle `vision`). Seit der Warm-Set-Diät 07.07. ist
// ON-DEMAND der NORMALZUSTAND (User-Entscheid: Augen nur, wenn Lucy an ist / IDE sie ruft;
// die Lucy-App wärmt sie beim Start selbst an). Ruhende Augen sind darum OK, keine Warnung.
checks.push(((): LucyCheck => {
const base = { id: "vision", label: "Lucys Augen", icon: Eye, critical: false }
if (!loaded || !modelsResp) return { ...base, status: "loading", detail: "Wird geprüft …" }
const vm = modelsResp.models.find((m) => m.role === "vision")
if (!vm)
return { ...base, status: "warn", detail: "Kein Augen-Modell eingerichtet — Lucy kann keine Bilder ansehen." }
const warm = (modelsResp.running ?? []).includes(vm.name)
return warm
? { ...base, status: "ok", detail: "Sieht gerade zu (geladen, solange Lucy sie nutzt)." }
: {
...base,
status: "ok",
detail: "Augen ruhen — sie laden von selbst, sobald Lucy startet oder ein Bild kommt.",
repair: { kind: "loadModel", model: vm.name, label: "Augen wecken" },
}
})())
// 3) Lucys Gedächtnis — Hermes Native Gedächtnis (integriert in hermes-gateway).
checks.push(((): LucyCheck => {
const base = { id: "memory", label: "Lucys Gedächtnis", icon: BookMarked, critical: true }
const ok = svcOk("hermes-gateway")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Hermes-Natives Gedächtnis aktiv (SQLite & Embeddings)." }
: {
...base,
status: "down",
detail: "Lucy kann sich gerade nichts merken — das Gateway antwortet nicht.",
repair: { kind: "restart", service: "hermes-gateway", label: "Gateway neu starten" },
}
})())
// 4) Verbindung (LLM-Gateway) — der Denkpfad, über den Apps/IDEs/Lucy die Modelle erreichen.
// Seit UMBAU v3 ist das der EIGENE mc2-gateway-Prozess; health.gateway_reachable prüft
// ihn jetzt wirklich (statt nur die Engine). Repariert wird die richtige Unit.
checks.push(((): LucyCheck => {
const base = { id: "gateway", label: "Verbindung (Gateway)", icon: Plug, critical: true }
const ok = health ? health.gateway_reachable : svcOk("mc2-gateway")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
const unit = svcHas("mc2-gateway") ? "mc2-gateway" : "mission-control-2"
return ok
? { ...base, status: "ok", detail: "Apps und IDEs können Lucy erreichen." }
: {
...base,
status: "down",
detail: "Der Modell-Gateway antwortet nicht — Lucy und die IDEs erreichen kein Modell.",
repair: { kind: "restart", service: unit, label: "Gateway neu starten" },
}
})())
// 4b) Der Wächter (Steward) — überwacht Warm-Set, Health-Flanken und Gedächtnis-Dubletten.
// Nicht kritisch fürs Antworten, aber ohne ihn merkt niemand, wenn etwas kippt.
checks.push(((): LucyCheck => {
const base = { id: "steward", label: "Wächter (Steward)", icon: ShieldAlert, critical: false }
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
if (!svcHas("mc2-steward"))
return { ...base, status: "ok", detail: "Auf dieser Installation nicht eingerichtet." }
return svcOk("mc2-steward")
? { ...base, status: "ok", detail: "Wacht über Warm-Set, Dienste und Gedächtnis." }
: {
...base,
status: "warn",
detail: "Der Wächter schläft — niemand meldet gerade Ausfälle oder wärmt Modelle nach.",
repair: { kind: "restart", service: "mc2-steward", label: "Wächter neu starten" },
}
})())
// 5) Lucys Agent (Hermes) — der Dienst, der als Lucy antwortet (Terminal/Telegram/Sprechen).
checks.push(((): LucyCheck => {
const base = { id: "agent", label: "Lucys Agent (Hermes)", icon: Bot, critical: true }
const ok = svcOk("hermes-gateway") // unit-Name (identisch zum alten Namens-Match, jetzt exakt)
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Lucy ist bereit zu reden und zu handeln." }
: {
...base,
status: "down",
detail: "Lucys Agent ist offline — sie reagiert gerade nicht.",
repair: { kind: "restart", service: "hermes-gateway", label: "Agent neu starten" },
}
})())
// 6) Lucys Stimme — Voice-Dienst (STT/TTS). Nicht kritisch: Chat geht auch ohne.
checks.push(((): LucyCheck => {
const base = { id: "voice", label: "Lucys Stimme", icon: Mic, critical: false }
const ok = svcOk("voice-service")
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
return ok
? { ...base, status: "ok", detail: "Lucy kann hören und sprechen." }
: {
...base,
status: "warn",
detail: "Sprechen ist gerade aus — Tippen geht weiter normal.",
repair: { kind: "restart", service: "voice-service", label: "Stimme neu starten" },
}
})())
// Speicher-Ampel = System-RAM, DIESELBE Quelle wie die RAM-Zeile in der
// System-Status-Kachel. Vorher zeigte die Pille den GTT-Pool (GPU-Sicht auf den
// Unified-Memory) — der weicht auf der APU vom System-RAM ab, wodurch „Box gesund"
// und „System-Status" zwei verschiedene Speicher-Zahlen zeigten. Eine Box, eine Zahl.
// Schwellen am RAM-Charakter geeicht: 4 Modelle warm ≈ 78 % gilt als gesund → grün.
const total = sys?.ram?.total ?? 0
const used = sys?.ram?.used ?? 0
const pct = sys?.ram?.percent ?? (total > 0 ? Math.min(100, (used / total) * 100) : 0)
const B = 1024 ** 3
const memory: MemoryAmpel = !total
? { status: "unknown", usedGb: 0, totalGb: 0, pct: 0, text: "Speicher-Auslastung unbekannt." }
: pct >= 93
? { status: "full", usedGb: used / B, totalGb: total / B, pct, text: "Speicher fast voll — Lucy könnte langsamer werden." }
: pct >= 85
? { status: "warn", usedGb: used / B, totalGb: total / B, pct, text: "Speicher gut gefüllt — noch okay." }
: { status: "ok", usedGb: used / B, totalGb: total / B, pct, text: "Genug Speicher frei." }
const anyLoading = checks.some((c) => c.status === "loading")
const problems = checks.filter((c) => c.critical && c.status === "down")
const warns = checks.filter((c) => c.status === "warn" || (!c.critical && c.status === "down"))
const memBad = memory.status === "full"
const verdict: Verdict = anyLoading && !problems.length
? "loading"
: problems.length
? "problem"
: warns.length || memBad
? "warn"
: "gut"
return { verdict, checks, memory, problems, warns, unreachable }
}
-42
View File
@@ -1,42 +0,0 @@
import { useMemo } from "react"
import { useSystemStatus } from "@/lib/queries"
import { useLetzteMetrik, useSysHistory, type SysSample } from "@/lib/metricsStore"
import type { SystemStatus } from "@/lib/api"
export type { SysSample }
/** Aktuelle System-Werte (für die Legende) + rollende Live-Historie.
*
* v3-Umbau P4: Die Query läuft nur noch minütlich, solange der Ereignisstrom steht —
* ihre MESSWERTE wären damit bis zu 60 s alt. Deshalb werden sie hier mit dem letzten
* Messpunkt aus dem Strom überschrieben. Alles, was der Strom NICHT trägt (VRAM-Detail,
* Kernzahl, Versionen), bleibt aus der Query: Der Strom soll schlank bleiben, und diese
* Felder ändern sich ohnehin selten.
*/
export function useSystemHistory() {
const { data: query, error } = useSystemStatus()
const live = useLetzteMetrik()
const hist = useSysHistory()
const sys = useMemo<SystemStatus | undefined>(() => {
if (!query) return query
if (!live) return query
return {
...query,
cpu: { ...query.cpu, percent: live.cpu ?? query.cpu.percent },
ram: {
...query.ram,
percent: live.ram ?? query.ram.percent,
used: live.ram_used ?? query.ram.used,
total: live.ram_total ?? query.ram.total,
},
// gtt_used/gtt_total bleiben aus der Query — der Strom trägt nur die Auslastung.
gpu: query.gpu ? { ...query.gpu, busy_percent: live.gpu ?? query.gpu.busy_percent } : query.gpu,
disk: query.disk ? { ...query.disk, percent: live.disk ?? query.disk.percent } : query.disk,
temp: { cpu: live.temp_cpu ?? query.temp?.cpu, gpu: live.temp_gpu ?? query.temp?.gpu },
uptime_s: live.uptime_s ?? query.uptime_s,
}
}, [query, live])
return { sys, hist, error }
}
+1 -11
View File
@@ -1,11 +1 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function resolveExternalUrl(url?: string): string {
if (!url) return ""
return url.replace(/127\.0\.0\.1|localhost/g, "192.168.178.151")
}
export { cn } from "cn"
+64
View File
@@ -0,0 +1,64 @@
import { flugplanZeit, gbText, laufzeit, seit, vor } from "./zeit"
import { rolleVon, temperaturFarbe, versionen } from "./anzeige"
import type { Modell } from "./typen"
// Fester Bezugspunkt: Mittwoch, 23.09.2026, 20:45 Uhr Berliner Zeit.
const JETZT = new Date("2026-09-23T20:45:00+02:00")
describe("flugplanZeit", () => {
it("benennt heute, gestern und morgen", () => {
expect(flugplanZeit("2026-09-23T03:00:00+02:00", JETZT)).toBe("HEUTE 03:00")
expect(flugplanZeit("2026-09-22T23:10:00+02:00", JETZT)).toBe("GESTERN 23:10")
expect(flugplanZeit("2026-09-24T03:00:00+02:00", JETZT)).toBe("MORGEN 03:00")
})
it("nimmt für die nächsten Tage den Wochentag", () => {
expect(flugplanZeit("2026-09-27T04:30:00+02:00", JETZT)).toBe("SO 04:30")
})
it("rechnet UTC-Zeiten in Berliner Zeit um", () => {
expect(flugplanZeit("2026-09-23T05:00:00Z", JETZT)).toBe("HEUTE 07:00")
})
})
describe("kleine Helfer", () => {
it("seit und vor", () => {
expect(seit(new Date("2026-09-07T11:03:00+02:00").getTime() / 1000, JETZT)).toBe("seit 07.09., 11:03")
expect(vor(JETZT.getTime() / 1000 - 40, JETZT.getTime())).toBe("vor 40 s")
expect(vor(JETZT.getTime() / 1000 - 2 * 86_400, JETZT.getTime())).toBe("vor 2 Tagen")
})
it("laufzeit und gbText", () => {
expect(laufzeit(10 * 86_400 + 14 * 3600 + 59)).toEqual([10, 14])
expect(gbText(55 * 1024 ** 3)).toBe("55 GB")
expect(gbText(1.9 * 1024 ** 4, 1)).toBe("1,9 TB")
})
it("Temperaturfarbe wechselt bei 70 und 85 Grad", () => {
expect(temperaturFarbe(37)).toBe("var(--gruen)")
expect(temperaturFarbe(70)).toBe("var(--bernstein)")
expect(temperaturFarbe(85)).toBe("var(--rot)")
})
})
describe("versionen", () => {
it("zeigt Motor-Builds mit b und nur echte Neuerungen", () => {
expect(versionen("engine", { kind: "engine", installed_build: 11057, latest_build: 11139 })).toEqual({ laeuft: "b11057", neu: "b11139" })
expect(versionen("engine", { kind: "engine", installed_build: 11139, latest_build: 11139 })).toEqual({ laeuft: "b11139", neu: null })
})
it("zählt beim Betriebssystem die Pakete", () => {
expect(versionen("os", { kind: "os", count: 24 })).toEqual({ laeuft: "Ubuntu", neu: "24 Pakete" })
})
})
describe("rolleVon", () => {
const m = (aliases: string[]): Modell => ({
name: "x", role: null, aliases, ctx: null, ttl: null, filename: "", quant: "", size_bytes: null, spec_active: false, parallel_slots: 1,
})
it("erkennt Hirn und Coder an den Aliasen", () => {
expect(rolleVon(m(["hermes", "fast"]))).toBe("hirn")
expect(rolleVon(m(["coder", "heavy"]))).toBe("coder")
expect(rolleVon(m(["debugger"]))).toBeNull()
})
})
+65
View File
@@ -0,0 +1,65 @@
// Anzeige-Helfer: Uhrzeiten, Dauer, Größen — alles auf Deutsch und in Box-Zeit.
const ZONE = "Europe/Berlin"
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: ZONE })
const tagKurz = new Intl.DateTimeFormat("de-DE", { weekday: "short", timeZone: ZONE })
const datumKurz = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", timeZone: ZONE })
const tagIso = new Intl.DateTimeFormat("en-CA", { timeZone: ZONE })
function tagSchluessel(d: Date) {
return tagIso.format(d)
}
/** "HEUTE 03:00", "GESTERN 23:10", "SA 08:00" (≤ 6 Tage entfernt), sonst "27.09. 04:30". */
export function flugplanZeit(iso: string, jetzt = new Date()): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return "–"
const heute = tagSchluessel(jetzt)
const gestern = tagSchluessel(new Date(jetzt.getTime() - 86_400_000))
const morgen = tagSchluessel(new Date(jetzt.getTime() + 86_400_000))
const t = tagSchluessel(d)
const zeit = uhr.format(d)
if (t === heute) return `HEUTE ${zeit}`
if (t === gestern) return `GESTERN ${zeit}`
if (t === morgen) return `MORGEN ${zeit}`
if (Math.abs(d.getTime() - jetzt.getTime()) < 6.5 * 86_400_000) {
return `${tagKurz.format(d).replace(".", "").toUpperCase()} ${zeit}`
}
// de-DE liefert „27.09.“ schon mit Schlusspunkt.
return `${datumKurz.format(d)} ${zeit}`
}
/** Unix-Sekunden → "seit 07.09., 11:03" bzw. "seit heute 07:02". */
export function seit(ts: number, jetzt = new Date()): string {
const d = new Date(ts * 1000)
if (tagSchluessel(d) === tagSchluessel(jetzt)) return `seit heute ${uhr.format(d)}`
return `seit ${datumKurz.format(d)}, ${uhr.format(d)}`
}
/** Unix-Sekunden → "vor 40 s", "vor 3 min", "vor 2 h", "vor 5 Tagen". */
export function vor(ts: number, jetzt = Date.now()): string {
const s = Math.max(0, Math.round(jetzt / 1000 - ts))
if (s < 60) return `vor ${s} s`
if (s < 3600) return `vor ${Math.round(s / 60)} min`
if (s < 86_400) return `vor ${Math.round(s / 3600)} h`
const tage = Math.round(s / 86_400)
return `vor ${tage} ${tage === 1 ? "Tag" : "Tagen"}`
}
/** Laufzeit in Sekunden → [Tage, Stunden]. */
export function laufzeit(s: number | null | undefined): [number, number] | null {
if (s == null || s < 0) return null
return [Math.floor(s / 86_400), Math.floor((s % 86_400) / 3600)]
}
export const gb = (bytes: number | null | undefined) =>
bytes == null ? null : bytes / 1024 ** 3
export function gbText(bytes: number | null | undefined, stellen = 0): string {
const g = gb(bytes)
if (g == null) return "–"
return g >= 1000 ? `${(g / 1024).toFixed(1).replace(".", ",")} TB` : `${g.toFixed(stellen).replace(".", ",")} GB`
}
export const zahl = (n: number) => n.toLocaleString("de-DE")
+10 -28
View File
@@ -1,41 +1,23 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { RouterProvider } from "@tanstack/react-router"
import { router } from "./app/router"
import { AppErrorBoundary } from "./components/AppErrorBoundary"
import { hashUmleiten } from "./lib/hashUmleitung"
import { veralteteVersionAbfangen } from "./lib/veralteteVersion"
import "./index.css"
// Beides MUSS vor dem ersten Rendern laufen:
// · hashUmleiten — alte Lesezeichen (#models) landen auf dem neuen Pfad (/modelle),
// bevor der Router die Adresse zum ersten Mal liest (P2).
// · veralteteVersionAbfangen — nach einem Deploy sucht ein offener Tab Nachlade-Chunks
// des alten Builds, die es nicht mehr gibt (P1).
hashUmleiten()
veralteteVersionAbfangen()
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
const queryClient = new QueryClient({
const abfragen = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5_000,
},
queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 5_000 },
},
})
// AppErrorBoundary bleibt als LETZTES Netz stehen — für Fehler außerhalb jeder Route
// (Provider, Router selbst). Innerhalb der Routen fängt seit P2 jede Ansicht für sich
// (app/router.tsx → errorComponent), damit ein Absturz nicht die ganze Shell mitnimmt.
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AppErrorBoundary>
</React.StrictMode>,
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={abfragen}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
)
-53
View File
@@ -1,53 +0,0 @@
import {
LayoutDashboard,
Boxes,
History,
Library,
Plug,
Bot,
SquareTerminal,
HelpCircle,
Lightbulb,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "ideen" | "skills" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
export interface NavItem {
id: ViewId
/** Echter URL-Pfad (v3-Umbau P2). Vorher war die Ansicht nur ein Hash (#models) —
* teilbar auf View-Ebene, aber ohne Platz für den Zustand DARUNTER. */
pfad: string
label: string
hint: string
icon: LucideIcon
group: NavGroup
}
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", pfad: "/cockpit", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
// Der Haupteingang: Idee → Konzept → Repo → IDE. Steht bewusst direkt unter dem
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
// Hinweis bewusst kurz: die App zeigt ihn oben in Großbuchstaben, direkt über der
// Seitenüberschrift „Ideen" — ein langer Satz stand dort dreifach gedoppelt.
{ id: "ideen", pfad: "/ideen", label: "Ideen", hint: "Schreib hin, was entstehen soll", icon: Lightbulb, group: "Operativ" },
{ id: "skills", pfad: "/skills", label: "Skills & Jobs", hint: "Autonome Skills manuell auslösen", icon: Bot, group: "Operativ" },
{ id: "chronik", pfad: "/chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
{ id: "wissen", pfad: "/wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
{ id: "connect", pfad: "/verbinden", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" },
{ id: "konsole", pfad: "/konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" },
{ id: "guide", pfad: "/anleitung", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle, group: "Werkzeuge" },
{ id: "models", pfad: "/modelle", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes, group: "Wartung" },
{ id: "agent", pfad: "/agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot, group: "Wartung" },
]
/** Nav-Eintrag zu einem Pfad — für Kopfzeile und aktive Markierung.
* Längster Treffer gewinnt, damit /ideen/<id> beim Eintrag „Ideen" landet. */
export function navFuerPfad(pfad: string): NavItem | undefined {
return [...NAV]
.sort((a, b) => b.pfad.length - a.pfad.length)
.find((n) => pfad === n.pfad || pfad.startsWith(n.pfad + "/"))
}
-350
View File
@@ -1,350 +0,0 @@
import { useState } from "react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Monitor, Shield, Check, X } from "lucide-react"
import { useNavigate } from "@tanstack/react-router"
import { Werkzeugverlauf } from "@/features/agent/Werkzeugverlauf"
import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
// Lane-/Gateway-Aliase, auf die das Hirn zeigen kann (Lane-Sprache statt altem „auto").
const ALIASES = ["chat", "coding", "fast", "heavy"]
export function AgentView() {
const navigate = useNavigate()
const { data: s, error: sErr } = useAgentStatus()
const { data: modelsData } = useModels()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const error = sErr ? String(sErr) : ""
const [showBrainSelect, setShowBrainSelect] = useState(false)
const installed = modelsData?.models ?? []
// Adressen aus dem Status-Payload statt hartcodiert — die UI zeigt, was konfiguriert IST.
const port = (url?: string) => url?.match(/:(\d+)/)?.[1] ?? ""
const gwPort = port(s?.gateway_url) || "8642"
const pcUrl = s?.pc_executor_url ?? ""
const pcHost = pcUrl.replace(/^https?:\/\//, "")
const pcPort = port(pcUrl) || "7777"
// Alias setzen → roher Config-Write (model.default zeigt aufs Alias).
async function setBrainAlias(alias: string) {
try {
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model: alias }) })
showAlert("Erledigt", `Hermes-Hirn zeigt jetzt auf das Alias '${alias}'. Der Gateway-Dienst wurde neu gestartet.`)
qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Wechsel fehlgeschlagen: ${e.message || e}`)
}
}
// Installiertes Modell → warm-bewusst (hermes-Alias + brains-Gruppe + Config + Restart).
async function setBrainWarm(modelName: string) {
try {
await api("/api/agent/brain/set", { method: "POST", body: JSON.stringify({ model_id: modelName }) })
showAlert("Erledigt", "Agent-Hirn gewechselt (warm gehalten). Gateway wurde neugestartet.")
qc.invalidateQueries({ queryKey: qk.agentStatus })
qc.invalidateQueries({ queryKey: qk.models })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Wechsel fehlgeschlagen: ${e.message || e}`)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<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">
Hermes Agenten-Cockpit
</h1>
<p className="text-sm text-muted-foreground">
Hermes ist ein autonomer Agent, der im Hintergrund der Box läuft (Port <code>:{gwPort}</code>) und seine eigene UI besitzt.
</p>
</div>
{s?.hermes_ui_url && (
<a
href={resolveExternalUrl(s.hermes_ui_url)}
target="_blank"
rel="noopener"
className={cn(
"flex h-9 px-4 items-center gap-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer shadow-md shrink-0 self-start",
s.hermes_ui_reachable
? "bg-primary text-primary-foreground hover:opacity-90 shadow-primary/10"
: "border border-border/60 text-muted-foreground bg-background/20"
)}
>
<ExternalLink className="h-4 w-4" />
<span>Hermes-GUI öffnen</span>
</a>
)}
</div>
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400 font-mono">
Status nicht lesbar ({error}).
</div>
)}
{s && (
<div className="space-y-6">
{/* Agent-Fluss: Terminal → Gateway → Hirn / Verdrahtung / PC (ruhig, statt SVG-Graph) */}
<div className="mc-card p-5">
<div className="mb-4 flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Agent-Fluss</span>
</div>
<div className="flex flex-col lg:flex-row lg:items-stretch gap-3">
{/* Quelle: Terminal/Clients */}
<button
onClick={() => s.hermes_ui_reachable && window.open(resolveExternalUrl(s.hermes_ui_url), "_blank")}
title={s.hermes_ui_reachable ? "Hermes-GUI öffnen" : "Desktop-Gateway offline"}
className="lg:w-40 shrink-0 rounded-xl border border-border/60 bg-background/30 p-3 text-left hover:border-primary/40 transition-all cursor-pointer flex flex-col justify-center"
>
<div className="flex items-center gap-1.5">
<Activity className={cn("h-3.5 w-3.5", s.hermes_ui_reachable ? "text-emerald-400" : "text-amber-500")} />
<span className="text-xs font-semibold text-foreground">Desktop &amp; GUI</span>
<span className={cn("ml-auto h-1.5 w-1.5 rounded-full animate-pulse", s.hermes_ui_reachable ? "bg-emerald-500" : "bg-amber-500")} />
</div>
<span className="text-[10px] text-muted-foreground font-mono mt-0.5">Hermes Desktop · Clients</span>
</button>
<div className="hidden lg:flex items-center text-muted-foreground text-lg select-none">→</div>
{/* Gateway + nachgelagerte Module */}
<div className="flex-1 flex flex-col gap-2.5">
{/* Agent Gateway */}
<div className="rounded-xl border border-primary/40 bg-primary/5 px-3.5 py-2.5 flex items-center gap-2">
<Bot className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-semibold text-primary">Agent Gateway</span>
<span className="text-[11px] font-mono text-muted-foreground">:{gwPort}</span>
<span className={cn(
"ml-auto inline-flex items-center gap-1.5 text-[10px] font-bold uppercase font-mono px-2 py-0.5 rounded-full border",
s.gateway_reachable
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-400 border-red-500/20"
)}>
<span className={cn("h-1.5 w-1.5 rounded-full", s.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-red-500")} />
{s.gateway_reachable ? "online" : "offline"}
</span>
</div>
{/* Drei Module: Hirn (klickbar), Verdrahtung, PC */}
<div className="grid gap-2.5 sm:grid-cols-3">
<button
onClick={() => setShowBrainSelect(true)}
className="rounded-xl border border-border/60 bg-background/30 p-3 text-left hover:border-primary/45 transition-all cursor-pointer"
>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Cpu className="h-3.5 w-3.5 text-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider">Hirn</span>
<span className="ml-auto text-[9px] text-primary/70 font-bold uppercase">wechseln →</span>
</div>
<div className="text-[11px] font-mono font-medium text-foreground truncate mt-1" title={s.brain_model}>
{s.brain_model || "chat"}
</div>
</button>
<div className="rounded-xl border border-border/60 bg-background/30 p-3">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Wrench className="h-3.5 w-3.5 text-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider">Verdrahtung</span>
</div>
<div className="text-[10px] font-mono mt-1 flex flex-wrap gap-x-2 text-muted-foreground">
<span>Config: {s.has_config ? "✓" : "—"}</span>
<span>Skills: {s.has_skills ? "✓" : "—"}</span>
<span>Memory: {s.has_memories ? "✓" : "—"}</span>
</div>
</div>
<div className="rounded-xl border border-border/60 bg-background/30 p-3">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Monitor className="h-3.5 w-3.5 text-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider">PC</span>
<span className={cn("ml-auto h-1.5 w-1.5 rounded-full", s.pc_executor_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
</div>
<div className={cn("text-[11px] font-mono mt-1", s.pc_executor_reachable ? "text-emerald-400" : "text-muted-foreground")}>
:{pcPort} {s.pc_executor_reachable ? "verbunden" : "offline"}
</div>
</div>
</div>
</div>
</div>
</div>
{/* PC Executor Status Card */}
<div className="mc-card p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-primary" />
<h3 className="text-sm font-bold uppercase tracking-wider text-foreground">PC Verbindung</h3>
</div>
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", s.pc_executor_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-semibold text-foreground">
{s.pc_executor_reachable ? "Erreichbar" : "Nicht verbunden"}
</span>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2 text-xs text-muted-foreground leading-relaxed text-left">
<div className="space-y-3">
<p>
Der <strong className="text-foreground">Hermes PC Executor</strong> läuft auf <code className="text-foreground font-mono px-1 py-0.5 rounded bg-muted/40 border border-border/30">{pcHost || "deinem Windows-PC"}</code> und ermöglicht Hermes den direkten Zugriff auf deinen Windows-PC — Shell-Befehle, Screenshots, Tastatureingaben.
</p>
<p>
Hermes erreicht den PC über den MCP-Server <code className="text-foreground font-mono px-1 py-0.5 rounded bg-muted/40 border border-border/30">hermes-pc-control</code>, der automatisch beim Gateway-Start verbunden wird.
</p>
</div>
<div className="space-y-3">
{s.pc_executor_reachable ? (
<div className="p-3.5 bg-emerald-500/5 border border-emerald-500/20 rounded-xl space-y-1">
<div className="text-emerald-400 font-semibold text-xs flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
PC Executor verbunden
</div>
<p className="text-[10px]">
Hermes kann jetzt per Telegram oder Terminal Befehle auf TobisNicerPC ausführen. Nutze <code className="text-foreground">pc_shell</code>, <code className="text-foreground">pc_screenshot</code> oder <code className="text-foreground">pc_open</code>.
</p>
</div>
) : (
<div className="space-y-2">
<h4 className="text-xs font-bold text-foreground">PC Executor starten</h4>
<p>Starte <code className="text-foreground font-mono">start.bat</code> im Ordner <code className="text-foreground font-mono">client\hermes-pc\</code> auf dem Windows-PC und lasse das Fenster offen.</p>
<div className="p-3 bg-background/25 rounded-xl border border-border/30 font-mono text-[9px] text-cyan-300">
client\hermes-pc\start.bat
</div>
</div>
)}
</div>
</div>
</div>
{/* Diagnostic Details if Offline */}
{!s.gateway_reachable && (
<div className="mc-card p-6 space-y-4 text-left">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-amber-500" />
<h3 className="text-sm font-bold uppercase tracking-wider text-foreground">Hermes-Agent Diagnostics</h3>
</div>
<div className="text-xs text-muted-foreground space-y-3 leading-relaxed">
<p>
Der Hermes-Dienst ist auf der Box derzeit offline oder kann sich nicht mit dem lokalen llama-swap verbinden.
</p>
<p>
Am schnellsten: In der System-Schublade unter <strong>Dienste</strong> den Eintrag{" "}
<strong>Hermes-Gateway</strong> mit einem Klick neu starten (dort gibt es auch die Logs dazu).
</p>
<button
onClick={() => navigate({ to: ".", search: (a) => ({ ...a, system: "wartung" as const }) })}
className="flex h-9 items-center gap-1.5 rounded-lg border border-primary/40 bg-primary/10 px-4 text-[11px] font-bold uppercase tracking-wide text-primary transition-all hover:bg-primary/20 cursor-pointer">
<Wrench className="h-3.5 w-3.5" /> Dienste-Übersicht öffnen
</button>
<p>
Weitere Einrichtungsdetails (Brain-Konfiguration, MCP-Kopplungen, SSH-Tunneling und Such-Engines) findest du in der Dokumentationsdatei:
<code className="ml-1 px-1.5 py-0.5 rounded bg-muted/40 text-foreground border border-border/30">docs/HERMES_SETUP.md</code>.
</p>
</div>
</div>
)}
</div>
)}
{/* Brain Selection Modal — eine Stelle für den Hirn-Wechsel (warm-bewusst + Alias) */}
{s && showBrainSelect && (
<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="Hermes-Hirn 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">
<Cpu className="h-4 w-4" />
<span>Hermes-Hirn konfigurieren</span>
</h3>
<button
onClick={() => setShowBrainSelect(false)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Lane / Gateway-Alias */}
<div className="space-y-1.5">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Lane / Gateway-Alias</div>
<div className="flex flex-wrap gap-1.5">
{ALIASES.map((a) => {
const cur = s.brain_model === a
return (
<button
key={a}
onClick={() => setBrainAlias(a)}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-mono font-semibold border transition-all cursor-pointer flex items-center gap-1.5",
cur ? "border-primary/40 bg-primary/10 text-primary" : "border-border/30 bg-background/20 text-foreground hover:bg-accent",
)}
>
{a}{cur && <Check className="h-3.5 w-3.5" />}
</button>
)
})}
</div>
<p className="text-[10px] text-muted-foreground/70">
Schlank &amp; flexibel: das Hirn folgt dem Lane-Routing (z.B. <code className="text-primary">chat</code> = fast↔heavy).
</p>
</div>
{/* Installierte Modelle (warm-bewusst) */}
<div className="space-y-1.5">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Installiertes Modell (warm gehalten)</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-1">
{installed.map((m) => {
const isBrain = m.role === "hermes"
return (
<button
key={m.name}
onClick={() => !isBrain && setBrainWarm(m.name)}
disabled={isBrain || m.incomplete}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs flex items-center justify-between font-mono border transition-all",
isBrain ? "border-indigo-500/40 bg-indigo-500/10 text-indigo-300"
: m.incomplete ? "border-border/20 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
: "border-border/30 bg-background/20 text-foreground hover:bg-accent cursor-pointer"
)}
>
<div className="flex flex-col min-w-0">
<span className="font-semibold truncate max-w-[280px]">{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{m.capabilities.params_b ? `${m.capabilities.params_b}B` : "?"} · {m.capabilities.tools !== "no" ? "Tools ✓" : "ohne Tools"}
{m.role && ` · Rolle: ${m.role}`}
</span>
</div>
{isBrain
? <span className="text-[9px] font-bold uppercase text-indigo-300 shrink-0 flex items-center gap-1"><Check className="h-3.5 w-3.5" /> Aktiv</span>
: <span className="text-[9px] font-bold uppercase text-primary/70 shrink-0">Warm setzen →</span>}
</button>
)
})}
</div>
</div>
<div className="text-[10px] text-amber-400/80 flex items-start gap-1.5 border-t border-border/20 pt-2.5">
<span>💡</span>
<span>Für einen <strong>Agenten</strong> sind Hermes-Modelle (natives Tool-Calling) meist die robustere Wahl. Neues Modell? Erst über „Modell-Manager → Modelle finden" laden.</span>
</div>
</div>
</div>
)}
{/* Werkzeug-Verlauf (v3-Umbau P6): was Lucy tatsaechlich getan hat, aus ihrem Log. */}
<Werkzeugverlauf />
{dialogElement}
</div>
)
}
-249
View File
@@ -1,249 +0,0 @@
import { useMemo, useState } from "react"
import { AlarmClock, Archive, Bell, HeartPulse, History, Inbox, Loader2, MessageCircle, Moon, Radar, RotateCcw, Search, ShieldAlert, Sparkles, type LucideIcon } from "lucide-react"
import { api, type ChronikItem, type Snapshot } from "@/lib/api"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { Dichteleiste } from "@/features/chronik/Dichteleiste"
import { useChronik, useZeitmaschine, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
// Chronik = die lesbare Geschichte dessen, was die Box von allein getan und gemeldet hat
// (Quelle: Melde-Briefkasten). Daneben die Zeitmaschine: Snapshots ansehen und per Klick
// zu einem Stand zurückkehren — mit hartem Bestätigungsdialog und Pre-Restore-Sicherung.
const SOURCE_UI: Record<string, { icon: LucideIcon; label: string; cls: string }> = {
sentry: { icon: HeartPulse, label: "Health-Wächter", cls: "text-red-300 bg-red-500/10 border-red-500/25" },
alarm: { icon: ShieldAlert, label: "Alarm", cls: "text-red-200 bg-red-500/15 border-red-500/40" },
notify: { icon: Bell, label: "Meldung", cls: "text-sky-300 bg-sky-500/10 border-sky-500/25" },
"chef-gutachter": { icon: Moon, label: "Morgenlage", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
radar: { icon: Radar, label: "Radar", cls: "text-teal-300 bg-teal-500/10 border-teal-500/25" },
auftragsbuch: { icon: Inbox, label: "Auftragsbuch", cls: "text-primary bg-primary/10 border-primary/25" },
zeitmaschine: { icon: RotateCcw, label: "Zeitmaschine", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
reminder: { icon: AlarmClock, label: "Erinnerung", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
traum: { icon: Sparkles, label: "Traum", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
}
const SOURCE_FALLBACK = { icon: MessageCircle, label: "Box", cls: "text-muted-foreground bg-background/40 border-border/50" }
function dayKey(ts: number): string {
return new Date(ts * 1000).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" })
}
export function ChronikView() {
const { data: alle = [], isLoading } = useChronik()
const [limitPerDay, setLimitPerDay] = useState(true)
// v3-Umbau P6: Das Suchwort steht in der URL (/chronik?q=…). So landet der
// „Finden"-Eintrag der Befehlspalette hier — und ein Treffer ist teilbar.
const navigate = useNavigate()
const { q = "" } = useSearch({ from: "/chronik" })
const suchen = (wert: string) =>
navigate({ to: "/chronik", search: wert ? { q: wert } : {}, replace: true })
const items = useMemo(() => {
const ql = q.trim().toLowerCase()
if (!ql) return alle
return alle.filter((it) =>
it.subject.toLowerCase().includes(ql)
|| it.text.toLowerCase().includes(ql)
|| it.source.toLowerCase().includes(ql))
}, [alle, q])
const grouped = useMemo(() => {
const g: { day: string; items: ChronikItem[] }[] = []
for (const it of items) {
const day = dayKey(it.ts)
const last = g[g.length - 1]
if (last && last.day === day) last.items.push(it)
else g.push({ day, items: [it] })
}
return g
}, [items])
return (
<div className="space-y-7">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Chronik
</h1>
<p className="text-sm text-muted-foreground">
Was deine Box von allein getan und gemeldet hat — und die Zeitmaschine, falls du zu einem früheren Stand zurück willst.
</p>
</div>
<Zeitmaschine />
<section className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<History className="h-3.5 w-3.5" /> Verlauf
{q && <span className="normal-case text-primary">· {items.length} von {alle.length}</span>}
</p>
<div className="relative ml-auto w-full sm:w-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
type="search"
value={q}
onChange={(e) => suchen(e.target.value)}
placeholder="Verlauf durchsuchen …"
aria-label="Chronik durchsuchen"
className="h-8 w-full rounded-lg border border-border/60 bg-background/40 pl-8 pr-3 text-xs text-foreground outline-none transition-colors focus:border-primary"
/>
</div>
</div>
{/* Zeigt, WANN die Box aktiv war — bevor man liest, WAS sie getan hat (§4.5). */}
{!isLoading && items.length > 0 && <Dichteleiste items={items} aktiv={!!q} />}
{isLoading && (
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Chronik wird geladen …
</div>
)}
{!isLoading && items.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
{q
? <>Nichts zu „{q}" im Verlauf. <button onClick={() => suchen("")} className="cursor-pointer text-primary underline underline-offset-2">Filter aufheben</button></>
: "Noch keine Einträge. Sobald die Box etwas von allein tut oder meldet (Updates, Wächter, Träume), steht es hier."}
</div>
)}
{grouped.map(({ day, items: dayItems }) => (
<div key={day}>
<p className="mb-2 text-xs font-bold text-foreground/70">{day}</p>
<div className="space-y-2 border-l-2 border-border/40 pl-4">
{(limitPerDay ? dayItems.slice(0, 8) : dayItems).map((it) => {
const ui = SOURCE_UI[it.source] ?? SOURCE_FALLBACK
const Icon = ui.icon
return (
<div key={it.id} className="relative rounded-xl border border-border/50 bg-card/40 p-3 backdrop-blur-sm">
<span className="absolute -left-[23px] top-4 h-2.5 w-2.5 rounded-full bg-border ring-4 ring-background/60" />
<div className="flex flex-wrap items-center gap-2">
<span className={cn("flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px] font-bold", ui.cls)}>
<Icon className="h-3 w-3" /> {ui.label}
</span>
{it.subject && <span className="text-[10px] font-semibold text-muted-foreground">{it.subject}</span>}
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">
{new Date(it.ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{it.text}</p>
</div>
)
})}
{limitPerDay && dayItems.length > 8 && (
<button onClick={() => setLimitPerDay(false)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
{dayItems.length - 8} weitere anzeigen
</button>
)}
</div>
</div>
))}
</section>
</div>
)
}
// ── Zeitmaschine: Snapshots + Ein-Klick-Restore ───────────────────────────────
function Zeitmaschine() {
const { data } = useZeitmaschine()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [busy, setBusy] = useState<string | null>(null)
const [backupBusy, setBackupBusy] = useState(false)
const [showAll, setShowAll] = useState(false)
const backups = data?.backups ?? []
const shown = showAll ? backups : backups.slice(0, 4)
function fmtSnap(s: Snapshot): string {
// Zeitstempel aus backup.sh ist kompakt (20260708-181136) — lesbar machen;
// tolerant auch für Varianten mit Trennzeichen (2026-07-08T18-11).
const m = s.snapshot.match(/^(\d{4})-?(\d{2})-?(\d{2})[T_-](\d{2})[-:]?(\d{2})/)
if (!m) return s.snapshot
return `${m[3]}.${m[2]}.${m[1]}, ${m[4]}:${m[5]} Uhr`
}
async function restore(s: Snapshot) {
setBusy(s.file)
try {
await api("/api/zeitmaschine/restore", { method: "POST", body: JSON.stringify({ file: s.file }) })
showAlert("Zeitmaschine läuft",
"Die Wiederherstellung wurde gestartet. Die Dienste starten gleich neu — die Zentrale ist kurz weg und meldet sich von selbst zurück. Vorher wurde automatisch ein Sicherheits-Backup des jetzigen Zustands gemacht.")
} catch (e: any) {
showAlert("Start fehlgeschlagen", String(e?.message || e))
} finally {
setBusy(null)
}
}
async function backupNow() {
setBackupBusy(true)
try {
const r = await api<{ ok: boolean; snapshot?: string; error?: string }>("/api/system/backup", { method: "POST" })
if (r.ok) showAlert("Gesichert", `Snapshot ${r.snapshot} wurde angelegt.`)
else showAlert("Backup fehlgeschlagen", r.error || "Unbekannter Fehler")
qc.invalidateQueries({ queryKey: qk.zeitmaschine })
} catch (e: any) {
showAlert("Backup fehlgeschlagen", String(e?.message || e))
} finally {
setBackupBusy(false)
}
}
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Archive className="h-3.5 w-3.5" /> Zeitmaschine — gesicherte Stände
</p>
<button onClick={backupNow} disabled={backupBusy || !data?.available}
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-card/45 px-2.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer disabled:opacity-50">
{backupBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Archive className="h-3.5 w-3.5" />} Jetzt sichern
</button>
</div>
{data && !data.available && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
Die Snapshots liegen auf der Box — im lokalen Dev-Modus nicht verfügbar.
</div>
)}
{data?.available && backups.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-5 text-center text-xs text-muted-foreground">
Noch keine Snapshots. Das nächtliche Backup (03:30) legt sie automatisch an — oder „Jetzt sichern".
</div>
)}
{shown.length > 0 && (
<div className="grid gap-2 sm:grid-cols-2">
{shown.map((s, idx) => (
<div key={s.file} className="flex items-center justify-between gap-3 rounded-xl border border-border/50 bg-card/40 p-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-foreground">
{fmtSnap(s)} {idx === 0 && <span className="ml-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-bold text-emerald-300">neuester</span>}
</p>
<p className="font-mono text-[10px] text-muted-foreground/70 truncate" title={s.file}>{s.size_mb.toLocaleString("de-DE")} MB · {s.file}</p>
</div>
<button
disabled={busy !== null}
onClick={() =>
showConfirm("Wirklich zu diesem Stand zurück?",
`Die Box wird auf den Snapshot vom ${fmtSnap(s)} zurückgesetzt (Gedächtnis, Hermes-Config, Modell-Router). Der JETZIGE Zustand wird vorher automatisch gesichert — der Schritt ist also umkehrbar. Die Dienste starten neu, die Zentrale ist kurz weg.`,
() => restore(s))}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2.5 text-[10px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50">
{busy === s.file ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />} Zurück
</button>
</div>
))}
</div>
)}
{backups.length > 4 && !showAll && (
<button onClick={() => setShowAll(true)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
Alle {backups.length} Snapshots anzeigen
</button>
)}
{dialogElement}
</section>
)
}
-315
View File
@@ -1,315 +0,0 @@
import { useState } from "react"
import {
Check, Copy, Cpu, Brain, Globe, Laptop, ArrowRight,
Info, CircleCheck, CircleX, Loader2, ChevronDown, ChevronRight,
} from "lucide-react"
import { useConnect, useConnectHealth } from "@/lib/queries"
import type { ConnectTool, ConnectLine } from "@/lib/api"
import { cn } from "@/lib/utils"
// Dateiziel je Tool — nur Hinweis im Editor-Kopf, keine Logik.
const FILE_NAMES: Record<string, string> = {
hermes_desktop: "Hermes Desktop: Browser-Login",
kilo: "Kilo: Einstellungen → API Provider",
claude_code: "env / Übersetzer",
}
function StatusBadge({ line, loading }: { line?: ConnectLine; loading: boolean }) {
if (loading || !line) {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> prüfe…
</span>
)
}
return line.ok ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-400">
<CircleCheck className="h-3 w-3" /> {line.detail}
</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-red-400">
<CircleX className="h-3 w-3" /> {line.detail}
</span>
)
}
// Schwarzes „Editor-Fenster" mit Ampel-Dots + Kopieren-Knopf.
function CodeWindow({
tool, fileName, accent, copied, onCopy,
}: {
tool: ConnectTool
fileName: string
accent: "teal" | "violet"
copied: boolean
onCopy: () => void
}) {
return (
<div className="flex flex-col border border-border/60 bg-black/60 rounded-2xl overflow-hidden shadow-2xl">
<div className="flex h-11 items-center justify-between px-4 border-b border-border/40 bg-black/40 shrink-0">
<div className="flex items-center gap-1.5">
<span className="h-3 w-3 rounded-full bg-red-500/80" />
<span className="h-3 w-3 rounded-full bg-amber-500/80" />
<span className="h-3 w-3 rounded-full bg-emerald-500/80" />
</div>
<div className="flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground/75 bg-background/30 px-3 py-1 rounded-md border border-border/20">
<span>{fileName}</span>
</div>
<button
onClick={onCopy}
className="flex h-7 px-2.5 items-center gap-1.5 rounded-lg border border-border/40 bg-background/30 text-[10px] font-semibold text-muted-foreground hover:text-foreground hover:bg-background/60 transition-all cursor-pointer"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
<span>{copied ? "Kopiert" : "Kopieren"}</span>
</button>
</div>
<pre className={cn(
"p-5 overflow-x-auto text-xs font-mono whitespace-pre leading-relaxed scrollbar-thin select-text bg-black/10",
accent === "teal" ? "text-cyan-200/90" : "text-violet-200/90",
)}>
<code>{tool.snippet}</code>
</pre>
</div>
)
}
// Die Box-Adresse kennt der Browser schon — er ist ja über sie verbunden. Nur im
// Dev-Modus (localhost + Vite-Proxy) greift der bekannte LAN-Fallback.
function defaultHost(): string {
const h = window.location.hostname
if (h && h !== "localhost" && h !== "127.0.0.1") return h
return "192.168.178.151"
}
export function ConnectView() {
const [host, setHost] = useState(localStorage.getItem("mc_host") || defaultHost())
// Legacy-Tools (roher /v1-Zugang) leben im Ausklapper — Hermes Desktop ist der Hauptweg.
const [active, setActive] = useState("kilo")
const [sonstigeOffen, setSonstigeOffen] = useState(false)
const [copied, setCopied] = useState<"desktop" | "model" | null>(null)
const params = new URLSearchParams({ host })
const { data, error: dataErr } = useConnect(params.toString())
const { data: health, isLoading: healthLoading } = useConnectHealth()
const error = dataErr ? String(dataErr) : ""
function saveHost(v: string) {
setHost(v)
if (v) localStorage.setItem("mc_host", v)
}
const desktop = data?.tools["hermes_desktop"]
const tool = data?.tools[active]
async function copy(which: "desktop" | "model", snippet?: string) {
if (!snippet) return
await navigator.clipboard.writeText(snippet)
setCopied(which)
setTimeout(() => setCopied(null), 1500)
}
return (
<div className="space-y-6">
{/* Header */}
<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">
Verbindung &amp; Integration
</h1>
<p className="text-sm text-muted-foreground">
<span className="text-foreground">Hermes Desktop</span> ist die Tür zur Box — einmal verbinden, fertig.
Für Spezialfälle gibt es darunter den rohen Gateway-Zugang (&nbsp;/v1) für sonstige Tools.
</p>
</div>
{/* Hero: die zwei Leitungen */}
<div className="p-5 mc-card">
<div className="grid gap-4 md:grid-cols-[170px_1fr] items-center">
{/* Lokaler Agent */}
<div className="rounded-2xl border border-border/60 bg-background/40 p-4 text-center">
<Laptop className="h-7 w-7 mx-auto text-muted-foreground" />
<div className="mt-2 text-sm font-semibold text-foreground">Dein lokaler Agent</div>
<div className="text-[11px] text-muted-foreground">Hermes Desktop · Kilo Code · Claude Code …</div>
</div>
{/* Zwei Leitungen */}
<div className="flex flex-col gap-2.5">
{/* Leitung 1 — Modell */}
<div className="flex items-center gap-3">
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 rounded-xl border border-primary/25 bg-primary/5 px-3.5 py-2.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-primary">
<Cpu className="h-3.5 w-3.5" /> Leitung 1 — Modell
</div>
<StatusBadge line={health?.gateway} loading={healthLoading} />
</div>
<div className="mt-1 text-[11px] font-mono text-primary/80">
Gateway · :9001/v1 · Modelle coder / heavy / vision
</div>
</div>
</div>
{/* Leitung 2 — Gedächtnis */}
<div className="flex items-center gap-3">
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 rounded-xl border border-violet-500/25 bg-violet-500/5 px-3.5 py-2.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-violet-300">
<Brain className="h-3.5 w-3.5" /> Leitung 2 — Gedächtnis
</div>
<StatusBadge line={health?.memory} loading={healthLoading} />
</div>
<div className="mt-1 text-[11px] font-mono text-violet-300/80">
Hermes-nativ · im Agenten · nichts einzurichten
</div>
</div>
</div>
{/* Leitung 3 — Desktop-Gateway */}
<div className="flex items-center gap-3">
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 rounded-xl border border-emerald-500/25 bg-emerald-500/5 px-3.5 py-2.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-300">
<Laptop className="h-3.5 w-3.5" /> Leitung 3 — Desktop-Gateway
</div>
<StatusBadge line={health?.desktop_gateway} loading={healthLoading} />
</div>
<div className="mt-1 text-[11px] font-mono text-emerald-300/80">
Desktop-Gateway · :9119 · API-Status
</div>
</div>
</div>
</div>
</div>
<p className="mt-3.5 text-[11px] text-muted-foreground leading-relaxed">
Der Gateway liefert <span className="text-foreground">nur das LLM</span>. Das Gedächtnis führt
<span className="text-foreground"> Hermes selbst</span> (nichts einzurichten — die Kachel zeigt nur, ob es lebt), und das
Desktop-Gateway bietet die Agenten-Schnittstelle. IDEs wählen die <span className="text-foreground">echten Modelle</span>
{" "}(coder · heavy · vision); daneben gibt es die virtuellen Lanes <span className="text-foreground">chat</span> und
{" "}<span className="text-foreground">coding</span>, die je Anfrage selbst das passende Modell wählen (Alltags-Chat, simple Tools).
</p>
</div>
{/* Box-Adresse — vorbefüllt aus der Browser-URL; anfassen nur, wenn sich die Box-IP ändert. */}
<div className="p-5 mc-card">
<div className="space-y-1.5 max-w-md">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-primary" /> Box LAN IP-Adresse
<span className="normal-case font-semibold tracking-normal text-muted-foreground/60">— vorbefüllt aus der Adresse dieser Seite</span>
</label>
<input
value={host}
onChange={(e) => saveHost(e.target.value)}
aria-label="Box LAN IP-Adresse"
spellCheck={false}
placeholder="z.B. 192.168.178.151"
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
<p className="text-[10px] text-muted-foreground/70">
Wird in alle Snippets eingebacken. Ändern musst du sie nur, wenn die Box eine neue IP bekommt.
</p>
</div>
</div>
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Fehler beim Generieren der Snippets: {error}
</div>
)}
{data && (
<div className="space-y-5">
{/* Hauptweg — Hermes Desktop anbinden */}
{desktop && (
<div className="space-y-3 rounded-2xl border border-border/60 border-t-2 border-t-emerald-500/70 bg-card/30 p-4">
<div>
<div className="flex items-center gap-2 text-sm font-space font-bold text-foreground">
<Laptop className="h-4 w-4 text-emerald-400" />
Hermes Desktop anbinden
<span className="text-[10px] font-medium text-emerald-400/90 normal-case">der Hauptweg</span>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
Die Desktop-App dockt remote an die Box an — volle Agent-Oberfläche, Projekte, Review, Gedächtnis inklusive.
</p>
</div>
{desktop.note && (
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-emerald-500/5 border border-emerald-500/20 text-[11px] text-muted-foreground leading-relaxed">
<Info className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />
<span>{desktop.note}</span>
</div>
)}
<CodeWindow
tool={desktop}
fileName={FILE_NAMES["hermes_desktop"]}
accent="teal"
copied={copied === "desktop"}
onCopy={() => copy("desktop", desktop.snippet)}
/>
</div>
)}
{/* Ausklapper — Legacy: roher /v1-Zugang + Gedächtnis-MCP für sonstige Tools */}
<div className="rounded-2xl border border-border/60 bg-card/30">
<button
onClick={() => setSonstigeOffen((v) => !v)}
className="flex w-full items-center gap-2 p-4 text-sm font-space font-bold text-foreground cursor-pointer hover:text-primary transition-colors"
>
{sonstigeOffen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
Sonstige Tools (roher /v1-Zugang)
<span className="text-[10px] font-medium text-muted-foreground normal-case">Kilo Code · Claude Code · Eigenbau</span>
</button>
{sonstigeOffen && (
<div className="grid gap-5 p-4 pt-0">
{/* Leitung 1 — Modell anbinden */}
<div className="space-y-3 rounded-2xl border border-border/60 border-t-2 border-t-primary/70 bg-card/30 p-4">
<div>
<div className="flex items-center gap-2 text-sm font-space font-bold text-foreground">
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-primary/15 text-primary text-[11px]">1</span>
Modell anbinden
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">Wähle dein Tool — das Snippet zeigt auf den Gateway.</p>
</div>
{/* Tool-Picker (ohne hermes_desktop — der hat oben seine eigene Karte) */}
<div className="flex flex-wrap gap-1.5">
{Object.entries(data.tools).filter(([key]) => key !== "hermes_desktop").map(([key, t]) => (
<button
key={key}
onClick={() => setActive(key)}
className={cn(
"rounded-lg px-3 py-1.5 text-[11px] font-semibold tracking-wide transition-all cursor-pointer",
active === key
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
: "border border-border/50 text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
</div>
{tool && (
<>
{tool.note && (
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-primary/5 border border-primary/20 text-[11px] text-muted-foreground leading-relaxed">
<Info className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<span>{tool.note}</span>
</div>
)}
<CodeWindow
tool={tool}
fileName={FILE_NAMES[active] || "config.json"}
accent="teal"
copied={copied === "model"}
onCopy={() => copy("model", tool.snippet)}
/>
</>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { useDienste, useProtokoll } from "@/lib/abfragen"
import { post } from "@/lib/api"
import { melden } from "@/lib/meldungen"
import { cn } from "cn"
/** Dienste der Box mit Protokoll und Neustart — die „Log-Ansicht der Dienste“. */
export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
const qc = useQueryClient()
const dienste = useDienste()
const [gewaehlt, setGewaehlt] = useState<string | null>(null)
const [bestaetigen, setBestaetigen] = useState<string | null>(null)
const protokoll = useProtokoll(gewaehlt)
async function neustarten(unit: string) {
setBestaetigen(null)
try {
const e = await post<{ ok: boolean; err?: string }>("/api/maintenance/restart", { service: unit })
if (e.ok) melden("erfolg", `${unit} startet neu.`)
else melden("fehler", e.err || `${unit} ließ sich nicht neu starten.`)
qc.invalidateQueries({ queryKey: ["dienste"] })
} catch (f) {
melden("fehler", (f as Error).message)
}
}
return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl">
<SheetHeader>
<SheetTitle className="schild text-lg">Dienste und Protokolle</SheetTitle>
<SheetDescription className="text-text-2">Welcher Dienst läuft, was er zuletzt geschrieben hat.</SheetDescription>
</SheetHeader>
<ul className="m-0 flex list-none flex-col px-4 pb-2">
{(dienste.data?.services ?? []).map((d) => (
<li key={d.unit} className="flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5">
<span className="flex min-w-0 items-center gap-2.5">
<span className={cn("size-2.5 shrink-0 rounded-full", d.ok ? "bg-gruen" : "bg-rot")} aria-hidden />
<span className="min-w-0">
<span className="block text-[15px]">{d.name}</span>
<span className="ziffern text-xs text-text-3">{d.unit} · {d.ok ? "läuft" : "antwortet nicht"}</span>
</span>
</span>
<span className="flex gap-2">
<Button variant={gewaehlt === d.unit ? "info" : "ghost"} size="sm" onClick={() => setGewaehlt(d.unit)}>
Protokoll
</Button>
{bestaetigen === d.unit ? (
<Button variant="gefahr" size="sm" onClick={() => neustarten(d.unit)}>Wirklich neu starten?</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setBestaetigen(d.unit)}>Neu starten</Button>
)}
</span>
</li>
))}
</ul>
{gewaehlt && (
<pre className="ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
{protokoll.isFetching ? "Wird gelesen …" : protokoll.data?.text || protokoll.data?.err || "Kein Protokoll."}
</pre>
)}
</SheetContent>
</Sheet>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { api, post } from "@/lib/api"
import { melden } from "@/lib/meldungen"
interface GeheimnisStand {
hf_token_gesetzt: boolean
hf_token_aus_env: boolean
schreibbar: boolean
}
export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
const qc = useQueryClient()
const stand = useQuery({
queryKey: ["geheimnisse"],
queryFn: () => api<GeheimnisStand>("/api/maintenance/geheimnisse"),
enabled: offen,
})
const [token, setToken] = useState("")
async function speichern(wert: string | null) {
try {
await post("/api/maintenance/geheimnisse", { schluessel: "hf_token", wert })
melden("erfolg", wert ? "Hugging-Face-Zugang gespeichert." : "Hugging-Face-Zugang gelöscht.")
setToken("")
qc.invalidateQueries({ queryKey: ["geheimnisse"] })
} catch (f) {
melden("fehler", (f as Error).message)
}
}
const s = stand.data
return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg">
<SheetHeader>
<SheetTitle className="schild text-lg">Einstellungen</SheetTitle>
<SheetDescription className="text-text-2">Was die Box sich merkt.</SheetDescription>
</SheetHeader>
<section className="flex flex-col gap-3 border-t border-linie px-4 pt-4">
<h3 className="schild text-sm text-text-3">Hugging-Face-Zugang</h3>
<p className="text-sm text-text-2">
{s?.hf_token_gesetzt ? "Ist hinterlegt." : "Fehlt."} Nötig für gesperrte Modelle und schnellere Downloads.
{s?.hf_token_aus_env ? " Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen." : ""}
</p>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault()
if (token.trim()) speichern(token.trim())
}}
>
<label htmlFor="hf-token" className="sr-only">Hugging-Face-Zugang</label>
<input
id="hf-token"
type="password"
autoComplete="off"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="hf_…"
className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan"
/>
<Button type="submit" disabled={!token.trim() || s?.schreibbar === false}>Speichern</Button>
</form>
{s?.hf_token_gesetzt && !s.hf_token_aus_env && (
<Button variant="ghost" size="sm" className="self-start" onClick={() => speichern(null)}>Zugang löschen</Button>
)}
</section>
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4">
<h3 className="schild text-sm text-text-3">Hinweise</h3>
<p className="text-sm text-text-2">
Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie
einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.
</p>
</section>
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4">
<h3 className="schild text-sm text-text-3">Hermes</h3>
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3">
<ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen
</a>
<p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p>
</section>
</SheetContent>
</Sheet>
)
}
-21
View File
@@ -1,21 +0,0 @@
import { Component, type ReactNode } from "react"
export class GraphErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null }
static getDerivedStateFromError(error: Error) {
return { error }
}
render() {
if (this.state.error) {
return (
<div className="h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto">
<div className="font-semibold mb-2">Graph konnte nicht gerendert werden</div>
<pre className="whitespace-pre-wrap break-words text-[11px] leading-relaxed">{this.state.error.message}</pre>
</div>
)
}
return this.props.children
}
}
-488
View File
@@ -1,488 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import ForceGraph2D from "react-force-graph-2d"
import { forceCollide, forceX, forceY } from "d3-force"
import { RefreshCw } from "lucide-react"
import { type MemoryGraph } from "@/lib/api"
const CAT_COLOR: Record<string, string> = {
identity: "#00f5ff", knowledge: "#3b82f6", rules: "#d946ef", events: "#fbbf24",
}
const CAT_LABEL: Record<string, string> = {
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
}
const EDGE_HI = "rgba(99, 102, 241, 0.9)"
const FALLBACK_EDGE = "rgba(100, 116, 139, 0.3)"
// Ruhefarben der Kanten je Kategorie EINMAL vorberechnen — niemals pro Frame rechnen.
const CAT_EDGE_COLOR: Record<string, string> = {
identity: "rgba(0, 245, 255, 0.3)", knowledge: "rgba(59, 130, 246, 0.3)",
rules: "rgba(217, 70, 239, 0.3)", events: "rgba(251, 191, 36, 0.3)",
}
// Halo-Farben (billiger Glow-Ersatz für ctx.shadowBlur, das pro Knoten/Frame unbezahlbar ist)
const CAT_HALO_COLOR: Record<string, string> = {
identity: "rgba(0, 245, 255, 0.16)", knowledge: "rgba(59, 130, 246, 0.16)",
rules: "rgba(217, 70, 239, 0.16)", events: "rgba(251, 191, 36, 0.16)",
}
// Cluster-Hüllen: Fläche, Rand und Landkarten-Label je Kategorie
const CAT_HULL_FILL: Record<string, string> = {
identity: "rgba(0, 245, 255, 0.05)", knowledge: "rgba(59, 130, 246, 0.05)",
rules: "rgba(217, 70, 239, 0.05)", events: "rgba(251, 191, 36, 0.05)",
}
const CAT_HULL_STROKE: Record<string, string> = {
identity: "rgba(0, 245, 255, 0.16)", knowledge: "rgba(59, 130, 246, 0.16)",
rules: "rgba(217, 70, 239, 0.16)", events: "rgba(251, 191, 36, 0.16)",
}
const CAT_HULL_LABEL: Record<string, string> = {
identity: "rgba(0, 245, 255, 0.3)", knowledge: "rgba(59, 130, 246, 0.3)",
rules: "rgba(217, 70, 239, 0.3)", events: "rgba(251, 191, 36, 0.3)",
}
// Konvexe Hülle (Andrew Monotone Chain) — bei ~300 Punkten pro Frame vernachlässigbar
function convexHull(pts: [number, number][]): [number, number][] {
if (pts.length < 3) return pts
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1])
const cross = (o: number[], a: number[], b: number[]) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
const lower: [number, number][] = []
for (const pt of p) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop()
lower.push(pt)
}
const upper: [number, number][] = []
for (let i = p.length - 1; i >= 0; i--) {
const pt = p[i]
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop()
upper.push(pt)
}
lower.pop(); upper.pop()
return lower.concat(upper)
}
export function GraphView({ data, selectedNodeId, onNodeSelect }: {
data: MemoryGraph
selectedNodeId: string | null
onNodeSelect: (id: string | null) => void
}) {
const [hovered, setHovered] = useState<string | null>(null)
// Gewichts-Schwelle: nur Kanten ab dieser Ähnlichkeit werden im Ruhezustand gezeichnet
const [threshold, setThreshold] = useState(0.75)
const containerRef = useRef<HTMLDivElement>(null)
const fgRef = useRef<any>(null)
const [dimensions, setDimensions] = useState({ width: 500, height: 560 })
// ResizeObserver für präzise Canvas-Dimensionen im Layout
useEffect(() => {
if (!containerRef.current) return
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
setDimensions({
width: Math.floor(entry.contentRect.width),
height: Math.floor(entry.contentRect.height),
})
}
}
})
resizeObserver.observe(containerRef.current)
return () => resizeObserver.disconnect()
}, [])
// Knoten-Objekte über Daten-Updates hinweg WIEDERVERWENDEN: D3 behält x/y auf dem
// Objekt — nur so überleben die Positionen Refetches und Suchfilter, statt dass der
// ganze Graph bei jedem Update neu explodiert (und Knoten unterm Cursor wegwandern).
const nodeCacheRef = useRef(new Map<string, any>())
const graphData = useMemo(() => {
const cache = nodeCacheRef.current
const seen = new Set<string>()
const nodes = data.nodes.map((n) => {
seen.add(n.id)
let node = cache.get(n.id)
if (!node) {
node = { ...n }
cache.set(n.id, node)
} else if (node.content !== n.content || node.category !== n.category) {
node.content = n.content
node.category = n.category
node.__baseTextWidth = undefined
}
return node
})
for (const id of Array.from(cache.keys())) {
if (!seen.has(id)) cache.delete(id)
}
const catById = new Map(data.nodes.map((n) => [n.id, n.category]))
const links = data.edges.map((e) => ({
source: e.source,
target: e.target,
weight: e.weight || 0,
// Stabile String-IDs behalten (D3 ersetzt source/target durch Objektreferenzen)
__sid: e.source,
__tid: e.target,
__sameCat: catById.get(e.source) === catById.get(e.target),
__restColor: CAT_EDGE_COLOR[catById.get(e.source) ?? ""] ?? FALLBACK_EDGE,
}))
return { nodes, links }
}, [data])
// Grad (Degree) berechnen
const degree = useMemo(() => {
const d: Record<string, number> = {}
data.edges.forEach((e) => {
d[e.source] = (d[e.source] || 0) + 1
d[e.target] = (d[e.target] || 0) + 1
})
return d
}, [data])
// Quadranten-Fokuskoordinaten für sauberes Clustering
const getClusterX = (category: string) => {
return {
identity: -240,
knowledge: 240,
rules: -240,
events: 240,
}[category] || 0
}
const getClusterY = (category: string) => {
return {
identity: -240,
knowledge: -240,
rules: 240,
events: 240,
}[category] || 0
}
// Reheat nur, wenn sich die STRUKTUR ändert (Knoten-/Kantenmenge) — nicht bei jeder
// neuen Objekt-Identität der Daten. Verhindert Layout-Neustarts bei blossen Refetches.
const structureKey = useMemo(() => {
return `${data.nodes.map((n) => n.id).sort().join(",")}#${data.edges.length}`
}, [data])
useEffect(() => {
const fg = fgRef.current
if (!fg) return
// 1. Standard-Zentrumskraft entfernen
fg.d3Force("center", null)
// 2. Clustering-Kräfte — etwas stärker, damit die Inseln klar auseinanderziehen
fg.d3Force("x", forceX().x((node: any) => getClusterX(node.category)).strength(0.1))
fg.d3Force("y", forceY().y((node: any) => getClusterY(node.category)).strength(0.1))
// 3. Starke Abstoßung (Charge) für ein weit gefächertes, luftiges Netz
const charge = fg.d3Force("charge")
if (charge) charge.strength(-380)
// 4. Konstanter, stabiler Kollisionsradius
fg.d3Force("collide", forceCollide().radius(28).iterations(2))
// 5. Federkraft (Links): Kanten innerhalb einer Kategorie ziehen stark an
// (gewichtet nach Ähnlichkeit), Querkanten fast gar nicht — so trennen sich
// die Inseln räumlich, statt vom Quernetz zusammengezurrt zu werden.
const link = fg.d3Force("link")
if (link) {
link
.distance((l: any) => (l.__sameCat ? 70 : 200))
.strength((l: any) => (l.__sameCat ? 0.3 * (l.weight || 0.6) : 0.01))
}
// Simulation einmalig heiss starten für die neue Struktur
fg.d3ReheatSimulation()
}, [structureKey])
const activeNode = hovered || selectedNodeId
// Anzahl der im Ruhezustand sichtbaren Kanten (für die Anzeige am Regler)
const visibleEdges = useMemo(() => {
return graphData.links.filter((l: any) => l.__sameCat && (l.weight || 0) >= threshold).length
}, [graphData, threshold])
// Layout zurücksetzen/neu anschubsen
const relayout = () => {
if (!fgRef.current) return
fgRef.current.zoomToFit(400, 40)
}
// Nachbarmenge für extrem schnellen Lookup
const connectedNodes = useMemo(() => {
if (!activeNode) return new Set<string>()
const set = new Set<string>()
data.edges.forEach((e) => {
if (e.source === activeNode) set.add(e.target)
if (e.target === activeNode) set.add(e.source)
})
return set
}, [activeNode, data])
// Sichtbarkeit statt Durcheinander: im Ruhezustand nur Insel-Kanten (gleiche
// Kategorie) ab der Gewichts-Schwelle. Querkanten erscheinen NUR am aktiven
// Knoten (Hover/Auswahl) — dort dann alle seine Verbindungen, auch schwache.
const linkVisibility = useCallback((link: any) => {
if (activeNode) return link.__sid === activeNode || link.__tid === activeNode
return link.__sameCat && (link.weight || 0) >= threshold
}, [activeNode, threshold])
const linkColor = useCallback((link: any) => {
if (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) return EDGE_HI
return link.__restColor
}, [activeNode])
const linkWidth = useCallback((link: any) => {
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 2.5 : 1.4
}, [activeNode])
const linkParticles = useCallback((link: any) => {
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 4 : 0
}, [activeNode])
// EIGENES Picking statt der Bibliotheks-Hit-Map: force-graph erkennt Hover/Klick
// über eine Schatten-Canvas, die nur alle 800 ms aktualisiert wird — solange die
// Simulation läuft oder gezoomt wird, zeigt die Erkennung auf Positionen von vor
// fast einer Sekunde. Wir rechnen stattdessen exakt gegen die LIVE-Koordinaten
// der Knoten (319 Distanzchecks pro Mausbewegung sind vernachlässigbar).
const pickNode = useCallback((clientX: number, clientY: number) => {
const fg = fgRef.current
const el = containerRef.current
if (!fg || !el) return null
const rect = el.getBoundingClientRect()
const { x: gx, y: gy } = fg.screen2GraphCoords(clientX - rect.left, clientY - rect.top)
const k = Math.max(fg.zoom() || 1, 0.01)
let best: any = null
let bestD2 = Infinity
for (const n of graphData.nodes as any[]) {
if (n.x == null || n.y == null) continue
const size = 6 + Math.min(degree[n.id] || 0, 15) * 0.8
// Trefferradius nie kleiner als ~16 Bildschirm-Pixel, egal wie weit rausgezoomt
const hitR = Math.max(size + 3, 16 / k)
const dx = n.x - gx
const dy = n.y - gy
const d2 = dx * dx + dy * dy
if (d2 <= hitR * hitR && d2 < bestD2) { bestD2 = d2; best = n }
}
return best
}, [graphData, degree])
const handlePointerMove = useCallback((ev: React.PointerEvent) => {
// Nur auf der Canvas picken — über Buttons/Legende gilt: kein Hover
const node = ev.target instanceof HTMLCanvasElement ? pickNode(ev.clientX, ev.clientY) : null
setHovered(node ? node.id : null)
if (containerRef.current) {
containerRef.current.style.cursor = node ? "pointer" : "default"
}
}, [pickNode])
const handlePointerLeave = useCallback(() => {
setHovered(null)
if (containerRef.current) containerRef.current.style.cursor = "default"
}, [])
// Klick selbst auswerten — aber nicht nach einem Pan (Mausweg > 6 px zählt nicht als Klick)
const downPosRef = useRef<{ x: number; y: number } | null>(null)
const handlePointerDown = useCallback((ev: React.PointerEvent) => {
downPosRef.current = { x: ev.clientX, y: ev.clientY }
}, [])
const handleClick = useCallback((ev: React.MouseEvent) => {
if (!(ev.target instanceof HTMLCanvasElement)) return // Buttons/Legende nicht abfangen
const down = downPosRef.current
if (down && Math.hypot(ev.clientX - down.x, ev.clientY - down.y) > 6) return
const node = pickNode(ev.clientX, ev.clientY)
onNodeSelect(node ? node.id : null)
}, [pickNode, onNodeSelect])
// Landkarten-Look: weiche, getönte Hülle + großes Label hinter jeder Kategorie-Insel.
// Läuft VOR Kanten/Knoten (onRenderFramePre); Konvexhülle über ~300 Punkte ist billig.
const paintHulls = useCallback((ctx: CanvasRenderingContext2D, globalScale: number) => {
const byCat: Record<string, [number, number][]> = {}
for (const n of graphData.nodes as any[]) {
if (n.x == null || n.y == null) continue
;(byCat[n.category] ??= []).push([n.x, n.y])
}
for (const [cat, pts] of Object.entries(byCat)) {
const fill = CAT_HULL_FILL[cat]
if (!fill || pts.length === 0) continue
const hull = convexHull(pts)
let cx = 0, cy = 0
hull.forEach((p) => { cx += p[0]; cy += p[1] })
cx /= hull.length; cy /= hull.length
const PAD = 34
ctx.beginPath()
if (hull.length < 3) {
ctx.arc(cx, cy, PAD + 16, 0, 2 * Math.PI)
} else {
// Hülle vom Schwerpunkt weg aufpolstern und durch Mittelpunkte glätten
const exp = hull.map(([x, y]) => {
const dx = x - cx, dy = y - cy
const d = Math.hypot(dx, dy) || 1
return [x + (dx / d) * PAD, y + (dy / d) * PAD] as [number, number]
})
const mid = (a: number[], b: number[]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
const m0 = mid(exp[exp.length - 1], exp[0])
ctx.moveTo(m0[0], m0[1])
for (let i = 0; i < exp.length; i++) {
const cur = exp[i]
const m = mid(cur, exp[(i + 1) % exp.length])
ctx.quadraticCurveTo(cur[0], cur[1], m[0], m[1])
}
ctx.closePath()
}
ctx.fillStyle = fill
ctx.fill()
ctx.strokeStyle = CAT_HULL_STROKE[cat]
ctx.lineWidth = 1.2 / Math.max(globalScale, 0.3)
ctx.stroke()
// Insel-Label in Weltkoordinaten — zoomt wie eine Landkarten-Beschriftung mit
const minY = Math.min(...hull.map((p) => p[1]))
ctx.font = '600 26px "Space Grotesk", sans-serif'
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillStyle = CAT_HULL_LABEL[cat]
ctx.fillText(CAT_LABEL[cat] || cat, cx, minY - PAD - 8)
}
}, [graphData])
const nodeCanvasObject = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const x = node.x ?? 0
const y = node.y ?? 0
const size = 6 + Math.min(degree[node.id] || 0, 15) * 0.8
const color = CAT_COLOR[node.category] || "#64748b"
const isHovered = node.id === hovered
const isSelected = node.id === selectedNodeId
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
const finalColor = isDimmed ? "#1e293b" : color
// 1. Billiger Glow: transparenter Halo-Kreis statt ctx.shadowBlur (das kostet bei
// hunderten Knoten pro Frame die gesamte Framerate). Echter Blur nur für den
// EINEN aktiven Knoten.
if (!isDimmed) {
ctx.beginPath()
ctx.arc(x, y, size * 1.9, 0, 2 * Math.PI, false)
ctx.fillStyle = CAT_HALO_COLOR[node.category] || "rgba(100, 116, 139, 0.14)"
ctx.fill()
}
if (isHovered || isSelected) {
ctx.shadowColor = color
ctx.shadowBlur = 15
}
ctx.beginPath()
ctx.arc(x, y, size, 0, 2 * Math.PI, false)
ctx.fillStyle = finalColor
ctx.fill()
ctx.shadowBlur = 0
if (isHovered || isSelected) {
ctx.beginPath()
ctx.arc(x, y, size + 2.5, 0, 2 * Math.PI, false)
ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)"
ctx.lineWidth = 1.5
ctx.stroke()
}
// 2. Label nur für den aktiven Fokus (Hovered, Selected & direkte Nachbarn)
const showLabel = isHovered || isSelected || (activeNode && connectedNodes.has(node.id))
if (showLabel) {
const label = node.content.length > 35 ? node.content.slice(0, 34) + "…" : node.content
// Textbreite EINMALIG auf dem Objekt cachen (ctx.measureText ist teuer)
if (node.__baseTextWidth === undefined) {
const prevFont = ctx.font
ctx.font = '500 10px "Space Grotesk", sans-serif'
node.__baseTextWidth = ctx.measureText(label).width
ctx.font = prevFont
}
const baseFontSize = isHovered || isSelected ? 10.5 : 9.5
const fontSize = baseFontSize / Math.max(0.5, globalScale)
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
// Breite linear zur geänderten FontSize skalieren — kein measureText pro Frame
const textWidth = node.__baseTextWidth * (fontSize / 10)
const bpad = 4.5 / Math.max(0.5, globalScale)
const rx = x - textWidth / 2 - bpad
const ry = y + size + (6 / Math.max(0.5, globalScale))
const rw = textWidth + bpad * 2
const rh = fontSize + bpad * 1.6
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.88)"
ctx.fillRect(rx, ry, rw, rh)
ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.08)"
ctx.lineWidth = 0.6 / Math.max(0.5, globalScale)
ctx.strokeRect(rx, ry, rw, rh)
ctx.textAlign = "center"
ctx.textBaseline = "top"
ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
ctx.fillText(label, x, y + size + (7 / Math.max(0.5, globalScale)))
}
}, [degree, hovered, selectedNodeId, activeNode, connectedNodes])
return (
<div className="absolute inset-0" ref={containerRef}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onPointerDown={handlePointerDown}
onClick={handleClick}
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
<ForceGraph2D
ref={fgRef}
width={dimensions.width}
height={dimensions.height}
graphData={graphData}
backgroundColor="rgba(0,0,0,0)"
cooldownTicks={120}
// Bibliotheks-Picking komplett aus: Hover/Klick machen wir selbst (exakt,
// ohne 800-ms-Throttle), Drag verschluckt sonst Klicks und schubst das Layout an.
enablePointerInteraction={false}
enableNodeDrag={false}
onRenderFramePre={paintHulls}
linkVisibility={linkVisibility}
linkColor={linkColor}
linkWidth={linkWidth}
linkDirectionalParticles={linkParticles}
linkDirectionalParticleWidth={2.2}
linkDirectionalParticleSpeed={0.006}
nodeCanvasObject={nodeCanvasObject}
/>
<button onClick={relayout} title="Zentrieren"
className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors cursor-pointer">
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren
</button>
{/* Legende + Verbindungs-Regler */}
<div className="absolute left-3 bottom-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm">
{Object.entries(CAT_LABEL).map(([k, l]) => (
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
</span>
))}
<label className="flex items-center gap-1.5 text-[10px] text-muted-foreground border-l border-border/40 pl-3">
<span>Verbindungen</span>
<span className="text-[9px] text-muted-foreground/60">alle</span>
<input
type="range" min={0.45} max={0.95} step={0.05} value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className="w-20 accent-primary cursor-pointer"
title="Nur Verbindungen ab dieser Stärke zeigen"
/>
<span className="text-[9px] text-muted-foreground/60">stark</span>
<span className="font-mono text-[9px] text-muted-foreground/80 min-w-[3ch] text-right">{visibleEdges}</span>
</label>
</div>
</div>
)
}
-528
View File
@@ -1,528 +0,0 @@
import { useState, type ReactNode } from "react"
import {
BookOpen, Boxes, Cpu, Layers, Plug, Bot,
Wrench, Shield, Compass, Code, Network, Database, Sparkles, Gauge,
Lock, Lightbulb, Server, Zap, ArrowUpRight, LifeBuoy, Puzzle,
type LucideIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { SystemSchaubild } from "./guide/SystemSchaubild"
/* ------------------------------------------------------------------ *
* Anleitung = Allgemeine AI-Bibel (Stand Juli 2026).
* Ganz oben „Dein System": Schaubild + Kreislauf der eigenen Box.
* Danach allgemeines Nachschlagewerk für lokale & agentische AI;
* Stack-Spezifika nur als "Bei dir konkret"-Callouts.
* ------------------------------------------------------------------ */
const SECTIONS: { id: string; label: string }[] = [
{ id: "system", label: "Dein System" },
{ id: "grundlagen", label: "Grundlagen" },
{ id: "moe", label: "MoE & Quant" },
{ id: "lokal", label: "Lokal betreiben" },
{ id: "modelle", label: "Modell-Landschaft" },
{ id: "gateway", label: "Gateway-Trick" },
{ id: "mcp", label: "MCP" },
{ id: "skills", label: "Skills" },
{ id: "memory", label: "Gedächtnis & RAG" },
{ id: "agents", label: "Agenten" },
{ id: "ide", label: "IDE anbinden" },
{ id: "tricks", label: "Tricks & Kniffe" },
{ id: "wartung", label: "Sicherheit & Wartung" },
{ id: "ressourcen", label: "Ressourcen" },
{ id: "troubleshooting", label: "Troubleshooting" },
]
function scrollToId(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" })
}
/* ---- kleine Bausteine ---- */
function Card({ id, icon: Icon, color, title, kicker, children, wide }: {
id?: string
icon: LucideIcon
color: string
title: string
kicker?: string
children: ReactNode
wide?: boolean
}) {
return (
<section
id={id}
className={cn(
"scroll-mt-20 mc-card p-6 space-y-3",
wide && "md:col-span-2"
)}
>
<div className="flex items-center gap-2.5 border-b border-border/20 pb-3">
<Icon className={cn("h-5 w-5 shrink-0", color)} />
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">{title}</h3>
{kicker && <span className={cn("text-[9px] font-mono", color)}>{kicker}</span>}
</div>
</div>
<div className="text-xs text-muted-foreground space-y-2.5 leading-relaxed">{children}</div>
</section>
)
}
/* "Bei dir konkret" — Stack-Einschub, nur wo eine Einschränkung es rechtfertigt */
function Stack({ children }: { children: ReactNode }) {
return (
<div className="mt-1 rounded-xl border border-primary/25 bg-primary/[0.06] p-3 text-[11px] leading-relaxed">
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
<Sparkles className="h-3 w-3" /> Bei dir konkret
</div>
<div className="text-muted-foreground space-y-1">{children}</div>
</div>
)
}
function Pill({ children }: { children: ReactNode }) {
return (
<code className="rounded bg-background/40 border border-border/30 px-1.5 py-0.5 font-mono text-[10px] text-foreground">
{children}
</code>
)
}
function LinkItem({ href, name, note }: { href: string; name: string; note: string }) {
return (
<li className="leading-relaxed">
<a
href={href}
target="_blank"
rel="noopener"
className="text-primary hover:underline font-semibold inline-flex items-center gap-0.5"
>
{name}
<ArrowUpRight className="h-3 w-3 opacity-60" />
</a>
<span className="text-muted-foreground"> — {note}</span>
</li>
)
}
export function GuideView() {
const [showAllNav, setShowAllNav] = useState(false)
return (
<div className="space-y-7">
{/* Hero */}
<div className="mc-card p-6 flex items-start gap-4">
<div className="h-11 w-11 rounded-xl bg-primary/15 flex items-center justify-center shrink-0">
<BookOpen className="h-6 w-6 text-primary" />
</div>
<div className="space-y-1">
<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">
Die AI-Bibel
</h1>
<p className="text-sm text-muted-foreground leading-relaxed max-w-2xl">
Erst <span className="font-semibold text-foreground">dein System auf einen Blick</span>, dann das
allgemeine Nachschlagewerk für lokale &amp; agentische AI — Konzepte, Tricks, Kniffe und
kuratierte Quellen. <span className="font-semibold text-foreground">Stand Juli 2026.</span>{" "}
<span className="text-primary font-semibold">„Bei dir konkret"</span>-Kästen
zeigen, was dein Stack daraus macht.
</p>
</div>
</div>
{/* Sprung-Navigation (sticky) */}
<div className="sticky top-0 z-10 -mx-1 px-1">
<div className="rounded-xl border border-border/50 bg-card/70 backdrop-blur-xl p-2 shadow-lg shadow-black/20">
<div className="flex items-center gap-1.5 flex-wrap">
<Compass className="h-3.5 w-3.5 text-primary ml-1 mr-0.5 shrink-0" />
{(showAllNav ? SECTIONS : SECTIONS.slice(0, 9)).map((s) => (
<button
key={s.id}
onClick={() => scrollToId(s.id)}
className="rounded-lg px-2.5 py-1 text-[11px] font-semibold text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors cursor-pointer"
>
{s.label}
</button>
))}
<button
onClick={() => setShowAllNav((v) => !v)}
className="rounded-lg px-2 py-1 text-[11px] font-semibold text-primary hover:bg-primary/10 transition-colors cursor-pointer"
>
{showAllNav ? "weniger" : "+ mehr"}
</button>
</div>
</div>
</div>
{/* 0 — Dein System (Schaubild) */}
<section id="system" className="scroll-mt-20 mc-card p-6 space-y-4">
<div className="flex items-center gap-2.5 border-b border-border/20 pb-3">
<Network className="h-5 w-5 text-primary" />
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">So funktioniert dein System</h3>
<span className="text-[9px] font-mono text-primary">Landkarte &amp; Kreislauf — das große Bild</span>
</div>
</div>
<p className="text-xs text-muted-foreground leading-relaxed max-w-3xl">
Vier Türen führen zur selben Box: <strong className="text-foreground">diese Oberfläche</strong> zum Steuern
und Abnicken, <strong className="text-foreground">Lucy</strong> für Zurufe per Stimme,
<strong className="text-foreground"> Hermes Desktop</strong> für richtige Projekte und
<strong className="text-foreground"> Telegram</strong> für unterwegs. In der Box denken die Modelle, das
Gedächtnis lernt automatisch mit — und nachts arbeitet sie allein: Sie träumt, inventarisiert sich selbst,
entwickelt Ideen zu Konzepten. Gebaut wird am PC in der IDE, jede Änderung geht als Branch nach Gitea.
<strong className="text-primary"> Nichts geht live ohne deinen Merge</strong> — die Ampel-CI muss grün sein,
danach spielt <code>deploy.sh</code> den Stand ein.
</p>
<SystemSchaubild />
</section>
<div className="grid gap-5 md:grid-cols-2">
{/* 1 — Grundlagen */}
<Card id="grundlagen" icon={Cpu} color="text-cyan-400" title="1. Wie LLMs ticken" kicker="Grundlagen">
<p>
Ein LLM ist im Kern ein <strong>Wahrscheinlichkeits-Rechner für das nächste Token</strong> (Wortteil).
Es „weiß" nichts — es setzt fort, was statistisch am plausibelsten ist. Daraus folgt fast alles andere.
</p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>Tokens</strong> sind die Einheit — ~¾ Wort. Ein- und Ausgabe werden in Tokens gezählt.</li>
<li><strong>Kontextfenster</strong> = wie viele Tokens das Modell gleichzeitig „sehen" kann (Prompt + Antwort). Voll = Anfang fällt raus.</li>
<li><strong>Parameter</strong> = die trainierten Gewichte. Training ist einmalig, <strong>Inferenz</strong> ist jede Antwort.</li>
<li><strong>Temperatur / Sampling</strong> steuert Zufall: niedrig = deterministisch/präzise (Code), hoch = kreativ.</li>
<li><strong>Halluzination</strong> ist kein Bug, sondern die Kehrseite des Ratens. Gegenmittel: Grounding via Tools/RAG, Verifikation.</li>
<li><strong>Reasoning-/Thinking-Modelle</strong> „denken" in unsichtbaren Tokens vor der Antwort — besser bei Logik, langsamer/teurer.</li>
</ul>
</Card>
{/* 2 — MoE & Quant */}
<Card id="moe" icon={Layers} color="text-violet-400" title="2. MoE & Quantisierung" kicker="Mehr Modell, weniger Last">
<p>
<strong>Mixture of Experts (MoE):</strong> Statt für jedes Token das ganze Netz zu aktivieren, wählt ein
<em> Router</em> nur ein paar spezialisierte <em>Experts</em> aus. So läuft ein 100B-Modell mit der aktiven
Rechenlast eines viel kleineren (z.B. „122B total / 10B aktiv").
</p>
<p>
<strong>Quantisierung:</strong> Gewichte von FP16 auf weniger Bits eindampfen — kleiner &amp; schneller bei
minimalem Qualitätsverlust. Faustregel:
</p>
<ul className="list-disc pl-4 space-y-1">
<li><Pill>Q8</Pill> nahezu verlustfrei, größter Footprint</li>
<li><Pill>Q6_K</Pill> sehr gut, guter Mittelweg</li>
<li><Pill>Q4_K_M</Pill> der Sweet-Spot für lokal — viel Modell pro GB</li>
</ul>
<Stack>
VRAM/RAM ist die harte Grenze — <strong>darum</strong> ist beides hier zentral: MoE lässt 100B+ überhaupt
laufen, Quant entscheidet, was reinpasst. Dein Line-up fährt durchweg <Pill>Q4_K_M</Pill>/<Pill>Q6_K</Pill>.
</Stack>
</Card>
{/* 3 — Lokal betreiben */}
<Card id="lokal" icon={Server} color="text-emerald-400" title="3. Lokal betreiben: Engines & Backends" kicker="llama.cpp, vLLM & Co." wide>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<p><strong>Inference-Engines</strong> — was die Modelle ausführt:</p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>llama.cpp / GGUF</strong> — der De-facto-Standard für lokal, CPU+GPU, läuft überall.</li>
<li><strong>Ollama / LM Studio</strong> — bequeme Wrapper mit One-Click-Downloads (LM Studio mit GUI).</li>
<li><strong>vLLM / TGI</strong> — Server-Engines für maximalen Durchsatz auf dicken GPUs.</li>
<li><strong>llama-swap</strong> — Proxy, der <em>mehrere</em> Modelle hinter <em>einem</em> Port hält und je nach Anfrage automatisch ein-/aussattelt.</li>
</ul>
</div>
<div className="space-y-2">
<p><strong>GPU-Backends</strong> — wie gerechnet wird:</p>
<ul className="list-disc pl-4 space-y-1">
<li><Pill>CUDA</Pill> NVIDIA · <Pill>ROCm</Pill> AMD · <Pill>Vulkan</Pill> herstellerübergreifend · <Pill>Metal</Pill> Apple</li>
</ul>
<p className="pt-1"><strong>Begriffe, die immer wiederkommen:</strong> Kontext (<Pill>-c</Pill>), Slots/Parallel, KV-Cache, Modell-TTL/Swap, <strong>Speculative Decoding</strong> (kleines Draft-Modell rät voraus → schneller).</p>
</div>
</div>
<Stack>
Deine Box hat eine AMD-APU (gfx1151). <strong>Einschränkung &amp; Trick:</strong> dort schlägt <Pill>Vulkan/RADV</Pill> das
offizielle <Pill>ROCm</Pill> bei Token-Generierung um ~12–22 % — darum läuft die Engine auf Vulkan.
<Pill>llama-swap</Pill> hält die Alltags-Hirne dauerwarm; <Pill>coder</Pill> nutzt Spec-Decoding.
</Stack>
</Card>
{/* 4 — Modell-Landschaft */}
<Card id="modelle" icon={Boxes} color="text-sky-400" title="4. Modell-Landschaft" kicker="Stand Juni 2026" wide>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<p><strong>Open-Weight (lokal nutzbar):</strong></p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>Qwen</strong> (Alibaba) — starke Allrounder + Coder + Vision, viele Größen/MoE.</li>
<li><strong>Llama</strong> (Meta), <strong>Gemma</strong> (Google), <strong>Phi</strong> (Microsoft) — solide Open-Familien.</li>
<li><strong>DeepSeek</strong>, <strong>Mistral/Mixtral</strong> — kräftige MoE-/Reasoning-Modelle.</li>
</ul>
</div>
<div className="space-y-2">
<p><strong>Frontier (Cloud-API):</strong></p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>Claude</strong> (Anthropic), <strong>GPT</strong> (OpenAI), <strong>Gemini</strong> (Google) — die stärksten Allrounder, aber nicht lokal.</li>
</ul>
<p className="pt-1"><strong>Modellwahl nach Aufgabe:</strong> schnelles Alltags-Hirn · großes Logik-Hirn für harte Tasks · dediziertes Code-Modell · multimodal für Bilder · Embedding-Modell fürs Gedächtnis.</p>
</div>
</div>
<p className="text-[11px] italic">
Leaderboards (HF, LMArena) sind grobe Orientierung — der einzige Benchmark, der zählt, ist deine eigene Aufgabe.
Vorsicht vor „Benchmaxxing".
</p>
<Stack>
Dein Line-up via llama-swap (Klartext-Rollen): <Pill>Hirn</Pill> (Agent, dauerwarm) · <Pill>Groß</Pill> (schwere
Logik) · <Pill>Coder</Pill> · <Pill>Augen</Pill> (Bilder) · <Pill>Späher</Pill> (schnelle Recherche) ·
<Pill>Kritiker</Pill> (Fremdblick/Review) · <Pill>Gedächtnis</Pill> + <Pill>Sortierer</Pill> (Embedding &amp;
Reranking). Verwalten/tauschen im <strong>Modelle</strong>-Tab.
</Stack>
</Card>
{/* 5 — Gateway-Trick */}
<Card id="gateway" icon={Network} color="text-amber-400" title="5. Der Gateway-Trick" kicker="OpenAI-kompatibel = überall andocken">
<p>
Fast jedes AI-Tool spricht heute das <strong>OpenAI-Format</strong> (<Pill>/v1/chat/completions</Pill>). Ein
<strong> Gateway</strong> davor gibt dir <em>einen</em> Endpunkt für alles: zentrales Key-Management, Logging
und transparentes <strong>Routing</strong> — der Client merkt nicht, welches Modell tatsächlich antwortet.
</p>
<p>
Die Lane-Idee: du wählst <Pill>chat</Pill> (Alltag, <Pill>auto</Pill> geht als Alias weiter) oder{" "}
<Pill>coding</Pill> (agentisch), das Gateway pickt das passende echte Modell und lädt es bei Bedarf.
</p>
<Stack>
Dein Gateway: <Pill>http://192.168.178.151:9001/v1</Pill>, Model <Pill>chat</Pill> oder <Pill>coding</Pill>, API-Key beliebig.
Fertige Configs erzeugt dir der <strong>Verbinden</strong>-Tab live — eine Wahrheit, kein Abtippen.
</Stack>
</Card>
{/* 6 — MCP */}
<Card id="mcp" icon={Plug} color="text-violet-400" title="6. MCP — Werkzeuge für Agenten" kicker="USB-C für AI-Tools">
<p>
Das <strong>Model Context Protocol</strong> (offen, von Anthropic initiiert) standardisiert, wie ein
AI-Client mit externen Tools &amp; Datenquellen spricht. Ein <strong>MCP-Server</strong> bringt Fähigkeiten:
Dateien, Web-Fetch, Git, Datenbanken, eigene APIs.
</p>
<p>Statt für jeden Editor eigene Tools zu schreiben, bindet jeder MCP-fähige Agent denselben Server an.</p>
<div className="flex items-start gap-1.5 rounded-xl border border-amber-500/25 bg-amber-500/[0.06] p-2.5 text-[11px]">
<Lock className="h-3.5 w-3.5 text-amber-400 shrink-0 mt-0.5" />
<span><strong>Sicherheit:</strong> ein MCP-Server hat echten Zugriff (Dateien, Shell). Nur vertrauenswürdige Server laufen lassen, Rechte minimal halten, Quelle prüfen.</span>
</div>
</Card>
{/* 7 — Skills */}
<Card id="skills" icon={Puzzle} color="text-emerald-400" title="7. Agent Skills" kicker="Wiederverwendbare Fähigkeiten">
<p>
Ein <strong>Skill</strong> ist ein Ordner mit einer <Pill>SKILL.md</Pill> (YAML-Kopf + Anleitung, optional
Skripte/Beispiele), den der Agent für eine bestimmte Aufgabe lädt — z.B. TDD, Code-Vereinfachung, API-Design.
</p>
<pre className="p-2.5 bg-background/25 rounded-lg border border-border/30 font-mono text-[9px] text-foreground overflow-x-auto whitespace-pre">
{`---
name: tdd-pro
description: Treibt Entwicklung mit strikter TDD-Praxis
---
# Instructions
...`}
</pre>
<p>
Suchen &amp; installieren über die <strong>skills.sh</strong>-Registry: <Pill>npx skills find</Pill> /
<Pill>npx skills add owner/repo</Pill>. Skills liegen projektweit (vom Agent beim Start gelesen) oder global.
</p>
</Card>
{/* 8 — Gedächtnis & RAG */}
<Card id="memory" icon={Database} color="text-indigo-400" title="8. Gedächtnis & RAG" kicker="Modelle vergessen — du nicht" wide>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<p>LLMs sind <strong>zustandslos</strong>: ohne Hilfe weiß das Modell beim nächsten Turn nichts mehr. Gedächtnis lebt extern.</p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>Embeddings</strong> wandeln Text in Vektoren; ein <strong>Vektor-Store</strong> (Chroma, …) findet semantisch Ähnliches.</li>
<li><strong>RAG</strong> = relevante Fakten vor dem Turn heraussuchen und einblenden statt alles im Prompt zu halten.</li>
<li><strong>Long-Context vs. RAG:</strong> riesige Fenster sind bequem, aber teuer &amp; „lost in the middle" — gezieltes Abrufen skaliert besser.</li>
</ul>
</div>
<div className="space-y-2">
<p><strong>Auto-lernendes Gedächtnis</strong> ist die nächste Stufe:</p>
<ul className="list-disc pl-4 space-y-1">
<li>extrahiert Fakten <em>selbst</em> aus Gesprächen, statt nur abzuspeichern</li>
<li>dedupliziert semantisch (kein 10× derselbe Fakt)</li>
<li><strong>Graph-Sicht</strong> = Fakten <em>plus</em> ihre Beziehungen, nicht nur eine flache Liste</li>
</ul>
</div>
</div>
<Stack>
Dein Gedächtnis führt <strong>Hermes</strong> selbst — auto-lernend und semantisch. Der Agent lernt im
Hintergrund nach jedem Turn, egal ob du per Desktop, Konsole, Telegram oder Lucy mit ihm sprichst.
<br />Deshalb hat das Steuerpult <strong>keinen eigenen Gedächtnis-Tab</strong>: bis August 2026 lag das
Gedächtnis in einem MC2-Sidecar, seit dessen Ablösung ist Hermes die einzige Quelle — angesehen und
gepflegt wird es dort, nicht hier. Unter <strong>Wissen</strong> findest du weiterhin die Graph-Sicht
auf Lucys Wissens-Vault.
<br /><strong>Gotcha:</strong> ein gelöschter Fakt kann wieder auftauchen, solange die Original-Nachricht noch im Verlauf steht (additive Extraktion).
</Stack>
</Card>
{/* 9 — Agenten */}
<Card id="agents" icon={Bot} color="text-rose-400" title="9. Autonome Agenten betreiben" kicker="LLM in der Schleife" wide>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<p>
Ein <strong>Agent</strong> ist ein LLM in einer Schleife mit Tools:
<em> denken → Tool aufrufen → Ergebnis beobachten → weiter</em>, bis das Ziel erreicht ist.
</p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>Guardrails</strong> (Hard-Stop, Tool-Limits) verhindern Endlos-Loops, wenn ein Tool fehlt oder hängt.</li>
<li><strong>Kontext-Hygiene</strong> ist die wichtigste Disziplin: frische Sessions starten — vollgemüllte Historien werden langsam, driften und halluzinieren.</li>
<li><strong>Kanäle:</strong> CLI/Terminal, Chat-UI, Messaging (z.B. Telegram), reine API.</li>
</ul>
</div>
<div className="space-y-2">
<p><strong>Least Privilege:</strong> gib einem Agenten nur die Tools, die er wirklich braucht. Kritische Aktionen (Shell auf dem Host, Löschen, Geld/Deploys) hinter menschliche Freigabe.</p>
<p><strong>Was sich vollautomatisch lohnt:</strong> Modell-Routing, Hintergrund-Gedächtnis, Recherche. <strong>Was Freigabe braucht:</strong> Systembefehle, Updates/Reboots, permanentes Löschen.</p>
</div>
</div>
<Stack>
<strong>Hermes</strong> ist dein Box-Agent mit eigenem Agent-Hirn (Rolle <Pill>Hirn</Pill>, dauerwarm).
Reden tust du mit ihm über <strong>Hermes Desktop</strong> (am PC, remote via <Pill>/hermes-ui</Pill>),
die <strong>Konsole</strong> (Box-Shell im Browser), <strong>Telegram</strong> — oder per Stimme über
<strong> Lucy</strong>. Er hat MCP-Server für Gedächtnis, Stack-Steuerung, Web-Fetch und
<strong> PC-Steuerung</strong> (Executor auf deinem Windows-PC). Nachts betreibt er Radar-Crons
und meldet Funde in Chronik, Briefkasten und Telegram.
Status &amp; Verdrahtung im <strong>Hermes</strong>-Tab.
</Stack>
</Card>
{/* 10 — IDE anbinden */}
<Card id="ide" icon={Code} color="text-cyan-400" title="10. IDE / Editor anbinden" kicker="Vibe-Coding lokal">
<p>
Jede <strong>OpenAI-kompatible</strong> IDE oder Extension lässt sich auf einen lokalen Server umbiegen —
Base-URL + Model eintragen, fertig:
</p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>Cline</strong> &amp; <strong>Roo Code</strong> (VS-Code-Extensions, voller Agent + MCP)</li>
<li><strong>Cursor</strong> (VS-Code-Fork mit Top-Autocomplete)</li>
<li><strong>Continue</strong>, <strong>aider</strong> (CLI), <strong>OpenCode</strong> (Desktop)</li>
</ul>
<Stack>
Deine Haupt-IDE ist inzwischen <strong>Hermes Desktop</strong> — sie ist ab Werk mit Box, Gedächtnis und
Skills verdrahtet, da ist nichts zu konfigurieren. Für alles andere gilt: Tipp den Kram nicht ab — der
<strong> Verbinden</strong>-Tab generiert die fertige Config (inkl. MCP-Gedächtnis) pro Tool zum Kopieren.
Essenz: Base <Pill>…:9001/v1</Pill>, Model <Pill>coder</Pill> (bauen) / <Pill>heavy</Pill> (denken) /{" "}
<Pill>vision</Pill> (Bilder); für simple Chat-Tools tut es die Lane <Pill>chat</Pill>. Key beliebig.
</Stack>
</Card>
{/* 11 — Tricks & Kniffe */}
<Card id="tricks" icon={Lightbulb} color="text-amber-400" title="11. Tricks & Kniffe" kicker="Was im Alltag wirklich spart" wide>
<div className="grid md:grid-cols-3 gap-3">
{[
["Kontext sauber halten", "Neue Session statt Mega-Historie. Drift, Tempo und Halluzination hängen direkt am Kontext-Müll."],
["Plan-then-Act", "Erst Plan/Vorgehen bestätigen lassen, dann ausführen. Spart teure Holzwege."],
["Gezielte Edits", "Nie ganze Dateien überschreiben für eine Zeile — Such-/Ersetz-Tools nutzen. Weniger Tokens, weniger Fehler."],
["Non-interaktive Befehle", "Keine interaktiven Prompts im Agent-Terminal; lange Tasks als Background-Job. Sonst hängt der Loop."],
["DevTools koppeln", "Browser-/Konsolen-Zugriff geben — der Agent liest echte Fehler statt blind zu raten."],
["Richtige Modellwahl", "Schnelles Hirn für Alltag, großes für harte Logik, Coder fürs Coden. Nicht alles mit der Kanone."],
["Akzeptanzkriterien", "Sag konkret, woran „fertig“ erkennbar ist. Vage Prompts → vage Ergebnisse."],
["Verifizieren statt vertrauen", "Tests/Live-Check fordern. „Sollte funktionieren“ ist kein Beweis."],
["Regeln ins Gedächtnis", "Wiederkehrende Vorlieben/Konventionen einmal ablegen — der Agent zieht sie selbst."],
].map(([t, d]) => (
<div key={t} className="space-y-1 p-3 bg-background/10 rounded-xl border border-border/30">
<div className="flex items-center gap-1.5 font-bold text-foreground text-[11px]">
<Zap className="h-3 w-3 text-amber-400" /> {t}
</div>
<p className="text-[11px]">{d}</p>
</div>
))}
</div>
</Card>
{/* 12 — Sicherheit & Wartung */}
<Card id="wartung" icon={Shield} color="text-emerald-400" title="12. Sicherheit & Wartung" kicker="Damit's auch morgen noch läuft" wide>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<p className="flex items-center gap-1.5 font-bold text-foreground"><Gauge className="h-3.5 w-3.5 text-emerald-400" /> Goldene Regeln</p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>Ein ungetesteter Restore ist kein Backup.</strong> Wiederherstellung mindestens einmal echt durchspielen.</li>
<li><strong>Updates können Dinge still zerschießen</strong> — gerade bei AI-Stacks (Embeddings, Provider-Interna). Versionen pinnen, Smoke-Test, Post-Check.</li>
<li><strong>Least Privilege</strong> für Agenten, MCP-Server und PC-Zugriff. Kritisches hinter Freigabe.</li>
</ul>
</div>
<div className="space-y-2">
<p className="flex items-center gap-1.5 font-bold text-foreground"><Wrench className="h-3.5 w-3.5 text-emerald-400" /> Bei dir im SystemDrawer</p>
<ul className="list-disc pl-4 space-y-1">
<li><strong>Backup:</strong> PBS-Backup-Zentrum (Sicherung auf den Proxmox-Server) + tägliche Tarball-Sicherung; Restore echt durchgespielt (<Pill>restore.sh</Pill>, Doku in <Pill>docs/BACKUP.md</Pill>).</li>
<li><strong>Updates:</strong> git-basierter Update-Check; <strong>Update-Buttons</strong> mit anschließendem <strong>Gehirn-Check</strong> (rot, falls ein Update das Gedächtnis zerschießt).</li>
<li><strong>Deploy-Sicherheit:</strong> <code>deploy.sh</code> sichert vorher und prüft danach die Gesundheit; die Zeitmaschine in der Chronik ist der Rückweg.</li>
<li><strong>Dienste &amp; Gefahrenzone:</strong> Restart/Logs pro Dienst, kritische Eingriffe getrennt.</li>
</ul>
<p className="text-[10px] italic">Erreichbar über das System-Icon → Tab „Wartung".</p>
</div>
</div>
</Card>
{/* 13 — Ressourcen */}
<section id="ressourcen" className="scroll-mt-20 md:col-span-2 mc-card p-6 space-y-4">
<div className="flex items-center gap-2.5 border-b border-border/20 pb-3">
<Compass className="h-5 w-5 text-primary" />
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">13. Kuratierte Ressourcen &amp; Links</h3>
<span className="text-[9px] font-mono text-primary">Sauber geordnet — die guten Quellen</span>
</div>
</div>
<div className="grid md:grid-cols-2 gap-5 text-xs">
<div className="space-y-2">
<div className="font-bold text-foreground flex items-center gap-1.5"><Boxes className="h-3.5 w-3.5 text-sky-400" /> Modelle &amp; Engines</div>
<ul className="list-disc pl-4 space-y-1.5">
<LinkItem href="https://huggingface.co/models" name="Hugging Face" note="das Repository für offene Modelle (GGUF, Embeddings, Datasets)." />
<LinkItem href="https://github.com/ggml-org/llama.cpp" name="llama.cpp" note="die Referenz-Engine für lokale Inferenz." />
<LinkItem href="https://github.com/mostlygeek/llama-swap" name="llama-swap" note="mehrere Modelle hinter einem Port, Auto-Swap." />
<LinkItem href="https://ollama.com" name="Ollama" note="einfachster Einstieg, One-Command-Modelle." />
<LinkItem href="https://lmstudio.ai" name="LM Studio" note="lokale GUI zum Stöbern, Laden &amp; Chatten." />
<LinkItem href="https://github.com/vllm-project/vllm" name="vLLM" note="Hochdurchsatz-Serving auf dicken GPUs." />
</ul>
</div>
<div className="space-y-2">
<div className="font-bold text-foreground flex items-center gap-1.5"><Plug className="h-3.5 w-3.5 text-violet-400" /> MCP &amp; Skills</div>
<ul className="list-disc pl-4 space-y-1.5">
<LinkItem href="https://modelcontextprotocol.io" name="MCP — offizielle Doku" note="Spezifikation &amp; Einstieg." />
<LinkItem href="https://github.com/modelcontextprotocol/servers" name="Offizielle MCP-Server" note="filesystem, git, postgres, fetch &amp; mehr." />
<LinkItem href="https://smithery.ai" name="Smithery" note="Registry zum Suchen &amp; Auto-Installieren von MCP-Servern." />
<LinkItem href="https://glama.ai/mcp/servers" name="Glama MCP Registry" note="kuratierte Community-Datenbank." />
<LinkItem href="https://skills.sh" name="skills.sh" note="Registry &amp; CLI für Agent-Skills." />
</ul>
</div>
<div className="space-y-2">
<div className="font-bold text-foreground flex items-center gap-1.5"><Bot className="h-3.5 w-3.5 text-rose-400" /> Agenten &amp; Frameworks</div>
<ul className="list-disc pl-4 space-y-1.5">
<LinkItem href="https://github.com/NousResearch/hermes-agent" name="Hermes Agent (Nous)" note="der Agent, der auf deiner Box läuft." />
<LinkItem href="https://docs.claude.com" name="Anthropic / Claude Docs" note="API, Tool-Use, Agent SDK, MCP." />
<LinkItem href="https://github.com/cline/cline" name="Cline" note="autonomer Coding-Agent für VS Code." />
<LinkItem href="https://aider.chat" name="aider" note="AI-Pair-Programming im Terminal, git-nativ." />
<LinkItem href="https://continue.dev" name="Continue" note="Open-Source-Autopilot für IDEs." />
</ul>
</div>
<div className="space-y-2">
<div className="font-bold text-foreground flex items-center gap-1.5"><BookOpen className="h-3.5 w-3.5 text-amber-400" /> Lernen &amp; Community</div>
<ul className="list-disc pl-4 space-y-1.5">
<LinkItem href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview" name="Prompt-Engineering (Anthropic)" note="die beste praktische Anleitung." />
<LinkItem href="https://github.com/anthropics/anthropic-cookbook" name="Anthropic Cookbook" note="lauffähige Rezepte für Tools, RAG, Agenten." />
<LinkItem href="https://www.promptingguide.ai" name="Prompt Engineering Guide" note="herstellerneutrales Nachschlagewerk." />
<LinkItem href="https://hermes-agent.nousresearch.com" name="Hermes Agent" note="die autonome Open-Source Agenten-Plattform unserer Box." />
<LinkItem href="https://www.reddit.com/r/LocalLLaMA/" name="r/LocalLLaMA" note="der Puls der lokalen-LLM-Szene." />
</ul>
</div>
</div>
</section>
{/* 14 — Troubleshooting */}
<Card id="troubleshooting" icon={LifeBuoy} color="text-rose-400" title="14. Troubleshooting" kicker="Wenn's hakt" wide>
<ul className="space-y-2 list-disc pl-4">
<li><strong>Keine Verbindung?</strong> Selbes LAN wie die Box? IP/Port stimmen (<Pill>:9001</Pill>)? Backend-Status im <strong>Cockpit</strong> prüfen.</li>
<li><strong>Modell antwortet nicht / langsam?</strong> Im <strong>Cockpit → Dienste</strong> schauen, ob <Pill>llama-swap</Pill> grün ist; sonst Restart. Erstes Token nach Swap dauert (Modell lädt).</li>
<li><strong>Agent dreht durch / halluziniert?</strong> Frische Session starten (Hermes Desktop oder <strong>Konsole</strong>) — meist ist es vollgemüllter Kontext.</li>
<li><strong>Gedächtnis findet nichts?</strong> Embedding-Dienst online? Semantisch suchen (Sinn statt exaktem Wort).</li>
<li><strong>Angenommene Karte hat etwas zerschossen?</strong> Meist fängt es der Auto-Revert; sonst <strong>Chronik → Zeitmaschine</strong> (Stand von gestern) oder Backup &amp; <Pill>restore.sh</Pill>.</li>
</ul>
</Card>
</div>
<p className="text-center text-[10px] text-muted-foreground/50 pt-2">
Mission Control 2 · AI-Bibel · Stand Juli 2026 — lebendes Dokument, wächst mit dem Stack.
</p>
</div>
)
}
-4
View File
@@ -1,4 +0,0 @@
// Wegweiser (v3-Umbau P5). Die Ansicht wohnt jetzt in features/ideen/.
// Diese Datei bleibt eine Fassung lang stehen, damit ein uebersehener Import nicht
// stillschweigend bricht — danach entfernen und den Router direkt zeigen lassen.
export { IdeenView } from "@/features/ideen/IdeenAnsicht"
-92
View File
@@ -1,92 +0,0 @@
import { useState } from "react"
import { SquareTerminal, ExternalLink, AlertTriangle, RefreshCw } from "lucide-react"
import { api } from "@/lib/api"
import { useAgentStatus, useQueryClient, qk, invalidate } from "@/lib/queries"
import { resolveExternalUrl, cn } from "@/lib/utils"
import { melden } from "@/app/store"
// Konsole = direkter, SSH-artiger Box-Zugriff (ttyd → Login-Shell auf :7682), eingebettet
// per iframe — Schwester der Hermes-Terminal-Seite. Login per Box-Passwort (su-Prompt im
// Terminal selbst). Die Passwort-Verwaltung liegt in System-Wartung → Einstellungen.
export function KonsoleView() {
const { data: agent } = useAgentStatus()
const qc = useQueryClient()
const url = agent?.box_console_url ? resolveExternalUrl(agent.box_console_url) : undefined
const ok = agent?.box_console_reachable
const [restarting, setRestarting] = useState(false)
const [restartMsg, setRestartMsg] = useState("")
// Ein-Klick-Reparatur statt SSH-Anweisung: box-console ist ein normaler User-Dienst.
async function restartConsole() {
setRestarting(true)
setRestartMsg("")
try {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service: "box-console" }),
})
const text = r.ok
? "Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal."
: `Neustart fehlgeschlagen: ${r.err || "Unbekannter Fehler"}`
setRestartMsg(text)
melden(r.ok ? "erfolg" : "fehler", text)
invalidate(qc, qk.agentStatus, qk.services)
} catch (e: any) {
const text = `Neustart fehlgeschlagen: ${e?.message || e}`
setRestartMsg(text)
melden("fehler", text)
} finally {
setRestarting(false)
}
}
return (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Konsole
</h1>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser.
</p>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
<span className={cn("h-2 w-2 rounded-full", ok ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
{ok ? "online" : "offline"}
</span>
{url && (
<a href={url} target="_blank" rel="noopener"
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground">
<ExternalLink className="h-3.5 w-3.5" /> In neuem Tab
</a>
)}
</div>
</div>
{url ? (
<div className="relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25">
{ok === false && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center">
<AlertTriangle className="h-8 w-8 text-amber-400" />
<div className="text-sm font-semibold text-amber-300">Konsole nicht erreichbar</div>
<p className="max-w-sm text-[11px] leading-normal text-muted-foreground">
Der Terminal-Dienst (<code className="font-mono text-primary">box-console</code>) läuft gerade nicht.
</p>
<button onClick={restartConsole} disabled={restarting}
className="flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50">
<RefreshCw className={cn("h-3.5 w-3.5", restarting && "animate-spin")} /> Dienst neu starten
</button>
{restartMsg && <p className="max-w-sm text-[11px] text-muted-foreground">{restartMsg}</p>}
</div>
)}
<iframe src={url} title="Box-Konsole" className="h-full w-full border-0" style={{ minHeight: "58vh" }} />
</div>
) : (
<div className="flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground">
<SquareTerminal className="mr-2 h-4 w-4" /> Lade Konsole…
</div>
)}
</div>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { api, post } from "@/lib/api"
import { melden } from "@/lib/meldungen"
import { zahl } from "@/lib/zeit"
interface Treffer { repo: string; downloads: number; likes: number }
interface Quant { quant?: string; name?: string; size_bytes?: number; total_bytes?: number }
/** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */
export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
const qc = useQueryClient()
const [eingabe, setEingabe] = useState("")
const [begriff, setBegriff] = useState("")
const [repo, setRepo] = useState<string | null>(null)
const [rolle, setRolle] = useState("")
const treffer = useQuery({
queryKey: ["hf-suche", begriff],
queryFn: () => api<{ results: Treffer[] }>(`/api/hf/search?q=${encodeURIComponent(begriff)}`),
enabled: begriff.length > 1,
})
const quants = useQuery({
queryKey: ["hf-quants", repo],
queryFn: () => api<{ repo: string; quants: Quant[] | string[] }>(`/api/hf/quants?repo=${encodeURIComponent(repo ?? "")}`),
enabled: !!repo,
})
async function installieren(quant: string) {
if (!repo) return
try {
await post("/api/models/install", { repo, quant, role: rolle || null })
melden("erfolg", `${repo} (${quant}) wird heruntergeladen. Den Fortschritt siehst du unter Updates.`)
qc.invalidateQueries({ queryKey: ["jobs"] })
qc.invalidateQueries({ queryKey: ["models"] })
onSchliessen()
} catch (f) {
melden("fehler", (f as Error).message)
}
}
const quantListe = (quants.data?.quants ?? []).map((q) => (typeof q === "string" ? { quant: q } : q))
return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-xl">
<SheetHeader>
<SheetTitle className="schild text-lg">Modelle selbst suchen</SheetTitle>
<SheetDescription className="text-text-2">Auf Hugging Face suchen, Quantisierung wählen, installieren.</SheetDescription>
</SheetHeader>
<form
className="flex gap-2 px-4"
onSubmit={(e) => {
e.preventDefault()
setRepo(null)
setBegriff(eingabe.trim())
}}
>
<label htmlFor="hf-suche" className="sr-only">Suchbegriff</label>
<input
id="hf-suche"
value={eingabe}
onChange={(e) => setEingabe(e.target.value)}
placeholder="z. B. Qwen3.8 GGUF"
className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan"
/>
<Button type="submit">Suchen</Button>
</form>
<div className="flex flex-col px-4 pb-4">
{treffer.isFetching && <p className="py-3 text-sm text-text-2">Suche läuft …</p>}
{!repo && treffer.data?.results.map((t) => (
<button
key={t.repo}
type="button"
onClick={() => setRepo(t.repo)}
className="flex min-h-11 items-center justify-between gap-3 border-t border-linie py-2 text-left hover:text-cyan"
>
<span className="ziffern min-w-0 text-sm break-all">{t.repo}</span>
<span className="ziffern shrink-0 text-xs text-text-3">{zahl(t.downloads)} ↓</span>
</button>
))}
{repo && (
<div className="flex flex-col gap-3 pt-2">
<div className="flex items-center justify-between gap-2">
<span className="ziffern text-sm break-all">{repo}</span>
<Button variant="ghost" size="sm" onClick={() => setRepo(null)}>Zurück</Button>
</div>
<label className="flex items-center gap-3 text-sm text-text-2">
Rolle
<select value={rolle} onChange={(e) => setRolle(e.target.value)} className="h-11 rounded-lg border border-linie-stark bg-erhaben px-3 text-foreground">
<option value="">ohne Rolle (nur installieren)</option>
<option value="coder">Coder</option>
</select>
</label>
{quants.isFetching && <p className="text-sm text-text-2">Quantisierungen werden gelesen …</p>}
{quantListe.map((q) => {
const name = q.quant ?? q.name ?? "?"
const bytes = q.total_bytes ?? q.size_bytes
return (
<div key={name} className="flex items-center justify-between gap-3 border-t border-linie py-2">
<span className="ziffern text-sm">{name}</span>
<span className="flex items-center gap-3">
{bytes ? <span className="ziffern text-xs text-text-3">{(bytes / 1024 ** 3).toFixed(1).replace(".", ",")} GB</span> : null}
<Button variant="outline" size="sm" onClick={() => installieren(name)}>Installieren</Button>
</span>
</div>
)
})}
</div>
)}
</div>
</SheetContent>
</Sheet>
)
}
+250
View File
@@ -0,0 +1,250 @@
import { useState } from "react"
import { Search } from "lucide-react"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import {
useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart,
} from "@/lib/abfragen"
import { rolleVon } from "@/lib/anzeige"
import { useLetzteMetrik } from "@/lib/strom"
import type { Modell, Nutzung, RadarKandidat } from "@/lib/typen"
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
import { ModellSuche } from "./ModellSuche"
/** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */
const GRENZE_GB = 115
function Speicher({ modelle, laufend }: { modelle: Modell[]; laufend: string[] }) {
const m = useLetzteMetrik()
const start = useStart()
const gesamt = gb(m?.ram_total ?? start.data?.box.ram_total) ?? 122.7
const belegt = gb(m?.ram_used ?? start.data?.box.ram_used) ?? 0
const geladen = modelle.filter((x) => laufend.includes(x.name))
const dateien = geladen.reduce((s, x) => s + (gb(x.size_bytes) ?? 0), 0)
const rest = Math.max(0, belegt - dateien)
const pct = (g: number) => `${Math.min(100, (g / gesamt) * 100)}%`
return (
<Panel titel="Speicher" rechts={<span className="ziffern text-sm text-text-2">{Math.round(belegt)} von {Math.round(gesamt)} GB belegt · Grenze ~{GRENZE_GB} GB</span>}>
<div className="relative h-8 rounded-lg bg-erhaben" role="img" aria-label={`Speicher: ${Math.round(belegt)} von ${Math.round(gesamt)} GB belegt`}>
<div className="absolute inset-y-0 left-0 flex overflow-hidden rounded-lg" style={{ width: pct(belegt) }}>
<div className="h-full bg-cyan" style={{ width: `${belegt ? (dateien / belegt) * 100 : 0}%` }} />
<div className="h-full bg-cyan/35" style={{ width: `${belegt ? (rest / belegt) * 100 : 0}%` }} />
</div>
<div className="absolute -top-1.5 -bottom-1.5 w-0.5 bg-bernstein" style={{ left: pct(GRENZE_GB) }} />
</div>
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm text-text-2">
<span className="flex items-center gap-2"><span className="size-2.5 rounded-sm bg-cyan" /> Geladene Modelle: {Math.round(dateien)} GB Dateien</span>
<span className="flex items-center gap-2"><span className="size-2.5 rounded-sm bg-cyan/35" /> Cache, Dienste, System: {Math.round(rest)} GB</span>
<span className="flex items-center gap-2"><span className="h-3 w-0.5 bg-bernstein" /> Ab hier wird es eng</span>
</div>
</Panel>
)
}
function Wert({ name, wert }: { name: string; wert: string }) {
return (
<div className="flex flex-col gap-1">
<dt className="text-xs text-text-3">{name}</dt>
<dd className="ziffern m-0 text-lg">{wert}</dd>
</div>
)
}
function Rolle({ titel, modell, laeuft, nutzer }: { titel: string; modell?: Modell; laeuft: boolean; nutzer: string }) {
const laden = useModellLaden()
if (!modell) {
return (
<article className="flex flex-col gap-3.5 rounded-2xl border border-dashed border-linie-stark p-5">
<div className="flex items-center justify-between"><span className="schild text-sm text-text-3">{titel}</span><Chip>Frei</Chip></div>
<h3 className="font-anzeige text-xl font-semibold text-text-2">Nicht belegt</h3>
<p className="text-sm text-text-2">Nur belegen, wenn ein Modell hier echten Mehrwert bringt. Das Radar meldet Kandidaten.</p>
</article>
)
}
const kontext = modell.ctx ? Math.round(modell.ctx / Math.max(1, modell.parallel_slots) / 1024) : null
const bilder = Boolean(modell.capabilities?.vision)
return (
<article className="flex flex-col gap-3.5 rounded-2xl border border-linie bg-panel p-5">
<div className="flex items-center justify-between gap-2">
<span className="schild text-sm text-text-3">{titel}</span>
<Chip art={laeuft ? "gruen" : "grau"}>{laeuft ? "Geladen" : "Auf Abruf"}</Chip>
</div>
<h3 className="ziffern m-0 text-lg font-medium break-all">{modell.name}</h3>
<dl className="m-0 grid grid-cols-3 gap-3">
<Wert name="Größe" wert={gbText(modell.size_bytes)} />
<Wert name="Kontext" wert={kontext ? `${kontext}k` : "–"} />
<Wert name="Bilder" wert={bilder ? "ja" : "nein"} />
</dl>
<p className="text-sm text-text-2">Genutzt von {nutzer}.{modell.spec_active ? " Mit Draft-Beschleunigung." : ""}</p>
{!laeuft && (
<Button variant="outline" size="sm" className="mt-auto self-start" onClick={() => laden.mutate(modell.name)} disabled={laden.isPending}>
Jetzt laden
</Button>
)}
</article>
)
}
function NutzungsPanel({ nutzung }: { nutzung: Nutzung | undefined }) {
if (!nutzung) return null
const max = Math.max(1, ...nutzung.absender.map((a) => a.anfragen))
const stunden = nutzung.letzte_24h.map((s) => Object.values(s.je_absender).reduce((a, b) => a + b, 0))
const maxStunde = Math.max(1, ...stunden)
return (
<Panel titel="Wer nutzt die Modelle" rechts={<span className="ziffern text-xs text-text-3">letzte {nutzung.tage} Tage</span>}>
{nutzung.absender.length === 0 && <p className="text-[15px] text-text-2">Keine Anfragen aufgezeichnet.</p>}
<ul className="m-0 flex list-none flex-col gap-3.5 p-0">
{nutzung.absender.map((a) => (
<li key={a.name} className="flex flex-col gap-1.5">
<div className="flex justify-between gap-3 text-sm"><span>{a.name}</span><span className="ziffern">{zahl(a.anfragen)}</span></div>
<div className="h-2 rounded-full bg-erhaben"><div className="h-2 rounded-full bg-cyan" style={{ width: `${(a.anfragen / max) * 100}%` }} /></div>
</li>
))}
</ul>
<div className="flex flex-col gap-1.5">
<span className="schild text-xs text-text-3">Anfragen je Stunde, letzte 24 h</span>
<svg viewBox="0 0 240 64" className="h-16 w-full" role="img" aria-label="Anfragen je Stunde in den letzten 24 Stunden">
{stunden.map((n, i) => {
const h = n ? Math.max(3, (n / maxStunde) * 50) : 2
return <rect key={i} x={i * 10 + 1.5} y={52 - h} width={7} height={h} rx={1.5} fill={n ? "var(--cyan)" : "var(--linie)"} />
})}
{[0, 6, 12, 18].map((s) => (
<text key={s} x={s * 10 + 5} y={63} textAnchor="middle" style={{ fontSize: 9, fill: "var(--text-3)", fontFamily: "var(--font-mono)" }}>{s}</text>
))}
</svg>
</div>
<p className="text-xs text-text-3">Anfragen an llama-swap. NerdQuiz geht direkt dorthin, alles andere über MC2.</p>
</Panel>
)
}
const STATUS_ART: Record<string, ChipArt> = {
bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan", getestet: "grau", uebernommen: "gruen", verworfen: "grau",
}
function Kandidat({ k }: { k: RadarKandidat }) {
const aktion = useRadarAktion()
const offen = ["bestanden", "wartet", "neu"].includes(k.status)
return (
<li className="flex flex-col gap-2 border-t border-linie py-3.5">
<div className="flex flex-wrap items-center gap-2">
<span className="ziffern text-[15px] font-medium">{k.name}</span>
<Chip art="cyan">{k.rolle === "hirn" ? "fürs Hirn" : "fürs Coden"}</Chip>
<Chip art={k.eng ? "bernstein" : k.passt ? "gruen" : "rot"}>{k.eng ? "passt knapp" : k.passt ? "passt" : "passt nicht"}</Chip>
<Chip art={STATUS_ART[k.status] ?? "grau"}>{k.status}</Chip>
</div>
<p className="text-sm text-text-2">
{k.groesse_gb != null ? `${k.groesse_gb.toLocaleString("de-DE")} GB. ` : ""}
{k.begruendung}
</p>
{offen && (
<div className="flex flex-wrap gap-2">
{k.status === "bestanden" && (
<Button size="sm" disabled={aktion.isPending} onClick={() => aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })}>
Übernehmen
</Button>
)}
<Button variant="outline" size="sm" disabled={aktion.isPending} onClick={() => aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })}>
Verwerfen
</Button>
</div>
)}
</li>
)
}
function RadarPanel() {
const radar = useRadar()
const suche = useRadarSuche()
if (radar.isError) {
return (
<Panel titel="Modell-Radar" id="radar">
<p className="text-[15px] text-text-2">Das Radar ist auf dieser Box noch nicht eingerichtet.</p>
</Panel>
)
}
const d = radar.data
return (
<Panel
titel="Modell-Radar"
id="radar"
rechts={<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}>Jetzt suchen</Button>}
>
<p className="-mt-2 text-sm text-text-2">
Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche.
{d?.naechster_test ? ` Nächster Test: ${flugplanZeit(d.naechster_test)}.` : ""}
</p>
{!d ? (
<p className="text-[15px] text-text-2">Wird gelesen …</p>
) : d.kandidaten.length === 0 ? (
<p className="text-[15px] text-text-2">Nichts Besseres in Sicht.</p>
) : (
<ul className="m-0 flex list-none flex-col p-0">
{d.kandidaten.map((k) => <Kandidat key={k.id} k={k} />)}
</ul>
)}
{d && d.tests.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="schild text-xs text-text-3">Letzte Tests</h3>
{d.tests.slice(0, 3).map((t) => (
<p key={t.id} className="text-sm text-text-2">
<span className="ziffern text-text-3">{flugplanZeit(t.datum)}</span> · {t.zusammenfassung}
</p>
))}
</div>
)}
</Panel>
)
}
export function ModelleSeite() {
const modelle = useModelle()
const nutzung = useNutzung()
const [suche, setSuche] = useState(false)
if (modelle.isPending) return <Zustandsfeld text="Modelle werden gelesen …" />
if (modelle.error || !modelle.data) return <Zustandsfeld fehler text={`Die Modell-Liste fehlt: ${modelle.error?.message ?? "keine Daten"}`} />
const liste = modelle.data.models
const laufend = modelle.data.running
const hirn = liste.find((m) => rolleVon(m) === "hirn")
const coder = liste.find((m) => rolleVon(m) === "coder")
const weitere = liste.filter((m) => m !== hirn && m !== coder)
const zuletzt = nutzung.data?.zuletzt_geladen ?? {}
return (
<>
<Speicher modelle={liste} laufend={laufend} />
<section aria-label="Rollen" className="grid gap-5 md:grid-cols-3 md:gap-6">
<Rolle titel="Hirn" modell={hirn} laeuft={!!hirn && laufend.includes(hirn.name)} nutzer="Lucy, NerdQuiz und OpenChamber für Kleinkram" />
<Rolle titel="Coder" modell={coder} laeuft={!!coder && laufend.includes(coder.name)} nutzer="OpenChamber zum Planen und Bauen und Lucys Delegation" />
<Rolle titel="Dritte Rolle" laeuft={false} nutzer="" />
</section>
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
<NutzungsPanel nutzung={nutzung.data} />
<RadarPanel />
</div>
<Panel
titel="Weitere Einträge"
rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>}
>
<ul className="m-0 flex list-none flex-col p-0">
{weitere.map((m) => (
<li key={m.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5 sm:grid-cols-[minmax(0,1fr)_120px_160px]">
<span className="min-w-0">
<span className="ziffern block text-sm break-all">{m.name}</span>
<span className="text-xs text-text-3">{m.aliases.length ? m.aliases.join(", ") : "ohne Rolle"}</span>
</span>
<span className="ziffern text-sm text-text-2">{gbText(m.size_bytes)}</span>
<span className="ziffern hidden text-xs text-text-3 sm:block">
{laufend.includes(m.name) ? "geladen" : zuletzt[m.name] ? `zuletzt ${flugplanZeit(zuletzt[m.name])}` : "länger nicht geladen"}
</span>
</li>
))}
</ul>
</Panel>
<ModellSuche offen={suche} onSchliessen={() => setSuche(false)} />
</>
)
}
-71
View File
@@ -1,71 +0,0 @@
import { useNavigate, useSearch } from "@tanstack/react-router"
import { cn } from "@/lib/utils"
import { JobsBar } from "@/components/models/JobsBar"
import { LaneEditor } from "@/components/models/LaneEditor"
import { WarmSetManager } from "@/components/models/WarmSetManager"
import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench"
import { Discover } from "./models/Discover"
// Modell-Manager: Werkbank (verwalten) · Routing & Warm-Set (Lanes-Policy + Immer-bereit-Set,
// vorher im toten Alt-Cockpit verwaist und damit UNERREICHBAR — Review 15.07.) · Entdecken.
const TABS = [
{ id: "werkbank", label: "Werkbank" },
{ id: "routing", label: "Routing & Warm-Set" },
{ id: "discover", label: "Modelle finden" },
] as const
type TabId = (typeof TABS)[number]["id"]
export function ModelsView() {
// v3-Umbau P2: Der Reiter lag in useState und war damit nicht teilbar und nicht
// reload-fest — wer jemandem „schau dir das Routing an" schicken wollte, schickte
// die Werkbank. Jetzt steht er in der URL (/modelle?reiter=routing).
const navigate = useNavigate()
const { reiter } = useSearch({ from: "/modelle" })
const tab: TabId = reiter ?? "werkbank"
const setTab = (t: TabId) =>
navigate({ to: "/modelle", search: (alt) => ({ ...alt, reiter: t }), replace: true })
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<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">
Modell-Manager
</h1>
<p className="text-sm text-muted-foreground">
Modelle verwalten, das Lane-Routing einstellen und Neues von HuggingFace holen.
</p>
</div>
<div className="flex mc-card-sm p-1 self-start">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
tab === t.id
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
: "text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
</div>
</div>
<JobsBar />
<div className="transition-all duration-300">
{tab === "werkbank" && <ModelsWorkbench />}
{tab === "routing" && (
<div className="space-y-5">
<LaneEditor />
<WarmSetManager />
</div>
)}
{tab === "discover" && <Discover />}
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More