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
-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>
)
}
-129
View File
@@ -1,129 +0,0 @@
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Bot, Play, Loader2, CheckCircle2, AlertTriangle } from "lucide-react"
import { cn } from "@/lib/utils"
import { api } from "@/lib/api"
import { qk } from "@/lib/queries"
import { JobsBar } from "@/components/models/JobsBar"
interface Skill {
name: string
description: string
running?: boolean
}
interface SkillsResp {
skills: Skill[]
}
export function SkillsView() {
const queryClient = useQueryClient()
const { data, isLoading, isError } = useQuery({
queryKey: ["skills-list"],
queryFn: () => api<SkillsResp>("/api/skills"),
refetchInterval: 5000,
})
const [runningSkill, setRunningSkill] = useState<string | null>(null)
const [lastResult, setLastResult] = useState<{name: string, ok: boolean, msg: string} | null>(null)
const mutation = useMutation({
mutationFn: (skillName: string) => api<{ok: boolean, msg?: string, err?: string}>("/api/skills/run", {
method: "POST",
body: JSON.stringify({ skill_name: skillName })
}),
onMutate: (skillName) => {
setRunningSkill(skillName)
setLastResult(null)
},
onSuccess: (res, skillName) => {
setRunningSkill(null)
setLastResult({ name: skillName, ok: res.ok, msg: res.msg || res.err || "Unbekannter Fehler" })
// Jobs neu laden, damit der gestartete Skill direkt in der JobsBar auftaucht
queryClient.invalidateQueries({ queryKey: qk.jobs })
queryClient.invalidateQueries({ queryKey: ["skills-list"] })
},
onError: (err, skillName) => {
setRunningSkill(null)
setLastResult({ name: skillName, ok: false, msg: String(err) })
}
})
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">
Skills & Jobs
</h1>
<p className="text-sm text-muted-foreground">
Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes.
</p>
</div>
</div>
<JobsBar />
{lastResult && (
<div className={cn(
"p-4 rounded-lg border flex gap-3 items-start",
lastResult.ok ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400" : "bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"
)}>
{lastResult.ok ? <CheckCircle2 className="h-5 w-5 mt-0.5 shrink-0" /> : <AlertTriangle className="h-5 w-5 mt-0.5 shrink-0" />}
<div>
<div className="font-semibold text-sm">{lastResult.name}</div>
<div className="text-sm opacity-90">{lastResult.msg}</div>
</div>
</div>
)}
{isLoading && (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{isError && (
<div className="text-red-500 p-4 mc-card text-sm">
Fehler beim Laden der Skills. Ist das Backend erreichbar?
</div>
)}
{data?.skills && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{data.skills.map(skill => (
<div key={skill.name} className="mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 font-semibold text-base font-space">
<Bot className="h-5 w-5 text-primary/80" />
{skill.name}
</div>
<button
onClick={() => mutation.mutate(skill.name)}
disabled={runningSkill === skill.name || skill.running || mutation.isPending}
className={cn(
"shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",
(runningSkill === skill.name || skill.running)
? "bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed"
: "bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"
)}
>
{(runningSkill === skill.name || skill.running) ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
{(runningSkill === skill.name || skill.running) ? "Läuft …" : "Starten"}
</button>
</div>
<div className="text-sm text-muted-foreground flex-1">
{skill.description}
</div>
</div>
))}
{data.skills.length === 0 && (
<div className="col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2">
Keine Skills im Ordner <code className="text-xs text-foreground bg-muted px-1.5 py-0.5 rounded">deploy/skills</code> gefunden.
</div>
)}
</div>
)}
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { Link } from "@tanstack/react-router"
import { Checkliste } from "@/components/cockpit/Checkliste"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Flugplan } from "@/components/cockpit/Flugplan"
import { Instrument } from "@/components/cockpit/Instrument"
import { Panel } from "@/components/cockpit/Panel"
import { Warnpanel } from "@/components/cockpit/Warnpanel"
import { Zaehlwerk } from "@/components/cockpit/Zaehlwerk"
import { Button } from "@/components/ui/button"
import { useRadar, useStart } from "@/lib/abfragen"
import { temperaturFarbe } from "@/lib/anzeige"
import { useLetzteMetrik } from "@/lib/strom"
import type { Radar, Start } from "@/lib/typen"
import { gb, gbText } from "@/lib/zeit"
function Instrumente({ box }: { box: Start["box"] }) {
// Live-Werte aus dem Strom (jede Sekunde), sonst der Stand der letzten Abfrage.
const m = useLetzteMetrik()
const ramBelegt = m?.ram_used ?? box.ram_used
const ramGesamt = m?.ram_total ?? box.ram_total
const temp = m?.temp_cpu ?? box.temp_cpu
const tempGpu = m?.temp_gpu ?? box.temp_gpu
const platte = box.platte
const uptime = m?.uptime_s ?? box.uptime_s
const ramAnteil = ramBelegt != null && ramGesamt ? ramBelegt / ramGesamt : null
return (
<section aria-label="Instrumente" className="grid grid-cols-2 gap-x-4 gap-y-6 rounded-2xl border border-linie bg-panel px-4 py-5 lg:grid-cols-4 lg:px-6">
<Instrument
beschriftung="Speicher"
anteil={ramAnteil}
mitte={ramBelegt != null ? String(Math.round(gb(ramBelegt) ?? 0)) : "–"}
unter={ramGesamt ? `von ${Math.round(gb(ramGesamt) ?? 0)} GB` : undefined}
ariaText={ramAnteil != null ? `Speicher: ${gbText(ramBelegt)} von ${gbText(ramGesamt)} belegt` : "Speicher unbekannt"}
/>
<Instrument
beschriftung="Temperatur"
anteil={temp != null ? temp / 100 : null}
farbe={temperaturFarbe(temp)}
zonen={[
{ von: 0.7, bis: 0.85, farbe: "var(--bernstein)" },
{ von: 0.85, bis: 1, farbe: "var(--rot)" },
]}
mitte={temp != null ? `${Math.round(temp)}°` : "–"}
unter={tempGpu != null ? `GPU ${Math.round(tempGpu)}°` : undefined}
ariaText={temp != null ? `Temperatur ${Math.round(temp)} Grad` : "Temperatur unbekannt"}
/>
<Instrument
beschriftung="Platte"
anteil={platte ? platte.percent / 100 : null}
farbe={platte && platte.percent >= 90 ? "var(--rot)" : platte && platte.percent >= 80 ? "var(--bernstein)" : "var(--cyan)"}
mitte={platte ? `${Math.round(platte.percent)}%` : "–"}
unter={platte ? `${gbText(platte.used)} / ${gbText(platte.total)}` : undefined}
ariaText={platte ? `Platte zu ${Math.round(platte.percent)} Prozent belegt` : "Platte unbekannt"}
/>
<Zaehlwerk sekunden={uptime} />
</section>
)
}
const RADAR_ART = { bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan" } as const
function RadarKasten({ radar }: { radar: Radar | undefined }) {
if (!radar) return null
const offen = radar.kandidaten.filter((k) => ["bestanden", "wartet", "neu"].includes(k.status))
const vorn = offen.find((k) => k.status === "bestanden") ?? offen[0]
return (
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 rounded-xl border border-cyan-rand bg-cyan-grund px-4 py-3.5">
<div className="flex min-w-0 flex-col gap-1">
<span className="schild text-sm text-cyan">
Radar · {offen.length} Kandidat{offen.length === 1 ? "" : "en"}
</span>
{vorn ? (
<span className="flex flex-wrap items-center gap-2 text-[15px]">
<span className="ziffern">{vorn.name}</span>
<span className="text-text-2">fürs {vorn.rolle === "hirn" ? "Hirn" : "Coden"}</span>
<Chip art={RADAR_ART[vorn.status as keyof typeof RADAR_ART] ?? "grau"}>{vorn.status}</Chip>
</span>
) : (
<span className="text-[15px] text-text-2">Nichts Besseres in Sicht.</span>
)}
</div>
<Button variant="info" size="sm" asChild>
<Link to="/modelle" hash="radar">Ansehen</Link>
</Button>
</div>
)
}
export function StartSeite() {
const { data: start, error, isPending } = useStart()
const radar = useRadar()
if (isPending) return <Zustandsfeld text="Die Box meldet sich gleich …" />
if (error || !start) return <Zustandsfeld fehler text={`MC2 antwortet nicht: ${error?.message ?? "keine Daten"}`} />
const offen = start.hinweise.length
return (
<>
<Warnpanel
zustand={start.zustand}
lampen={start.lampen}
onAnsehen={() => document.getElementById("checkliste")?.scrollIntoView({ behavior: "smooth", block: "start" })}
/>
<Instrumente box={start.box} />
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)] md:gap-6">
<Panel
titel="Checkliste"
id="checkliste"
rechts={<span className={offen ? "ziffern text-sm text-bernstein" : "ziffern text-sm text-gruen"}>{offen ? `${offen} offen` : "alles erledigt"}</span>}
>
<Checkliste hinweise={start.hinweise} verlauf={start.verlauf} />
</Panel>
<Panel titel="Flugplan">
<Flugplan gelaufen={start.flugplan.gelaufen} geplant={start.flugplan.geplant} />
<RadarKasten radar={radar.data} />
</Panel>
</div>
</>
)
}
+225
View File
@@ -0,0 +1,225 @@
import { useState } from "react"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import {
useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails, useUpdateStarten, useUpdateVerlauf,
useZurueckspielen,
} from "@/lib/abfragen"
import { versionen } from "@/lib/anzeige"
import type { Job, Sicherung } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const BAUSTEINE = [
{ id: "os", name: "Betriebssystem" },
{ id: "engine", name: "Motor (llama.cpp)" },
{ id: "swap", name: "llama-swap" },
{ id: "hermes", name: "Hermes" },
] as const
function BausteinZeile({ id, name, hatUpdate, onStart, laeuftGerade }: {
id: string
name: string
hatUpdate: boolean
onStart: () => void
laeuftGerade: boolean
}) {
const { data, isPending } = useUpdateDetails(id)
const v = versionen(id, data)
const neu = hatUpdate ? v.neu ?? "neu" : null
const was = !hatUpdate
? "Aktuell."
: data?.summary?.trim() || (id === "os" && data?.count ? "Paketliste unten in den Details." : isPending ? "Wird gelesen …" : "")
return (
<tr className="border-t border-linie align-top">
<th scope="row" className="py-3.5 pr-4 text-left font-sans text-[15px] font-semibold">{name}</th>
<td className="ziffern py-3.5 pr-4 text-sm text-text-2">{v.laeuft}</td>
<td className="ziffern py-3.5 pr-4 text-sm">{neu ? <span className="text-cyan">{neu}</span> : <span className="text-text-3">–</span>}</td>
<td className="py-3.5 pr-4 text-sm leading-relaxed text-text-2">
<span className="line-clamp-3 whitespace-pre-line">{was}</span>
</td>
<td className="py-2.5 text-right">
{hatUpdate && (
<Button variant="outline" size="sm" onClick={onStart} disabled={laeuftGerade}>
Aktualisieren
</Button>
)}
</td>
</tr>
)
}
function JobKasten({ job }: { job: Job & { group?: string; log?: string[] } }) {
const art = job.state === "done" ? "gruen" : job.state === "failed" ? "rot" : "cyan"
const text = job.state === "done" ? "Fertig" : job.state === "failed" ? "Fehler" : job.state === "queued" ? "Wartet" : "Läuft"
return (
<div className="flex flex-col gap-2 rounded-xl border border-linie bg-erhaben/50 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-[15px] font-semibold">{job.label}</span>
<Chip art={art}>{text}</Chip>
</div>
{job.log && job.log.length > 0 && (
<pre className="ziffern max-h-40 overflow-auto rounded-md bg-[#0b0d0f] p-2 text-xs whitespace-pre-wrap text-text-2">
{job.log.slice(-8).join("\n")}
</pre>
)}
</div>
)
}
function sicherungsZeit(s: Sicherung) {
return s.mtime ? flugplanZeit(new Date(s.mtime * 1000).toISOString()) : s.snapshot
}
export function UpdatesSeite() {
const start = useStart()
const jobs = useJobs()
const verlauf = useUpdateVerlauf()
const sicherungen = useSicherungen()
const updateStarten = useUpdateStarten()
const sichern = useJetztSichern()
const zurueck = useZurueckspielen()
const [frage, setFrage] = useState<null | { art: "alle" } | { art: "zurueck"; datei: string; zeit: string }>(null)
const mitUpdate = new Set(start.data?.updates.bausteine.map((b) => b.id) ?? [])
const wartungsJobs = (jobs.data?.jobs ?? []).filter((j) => (j as { group?: string }).group === "maintenance")
const laeuft = wartungsJobs.some((j) => j.state === "running" || j.state === "queued")
return (
<>
<Panel
titel="Updates"
rechts={
<div className="flex flex-wrap gap-2.5">
<Button variant="outline" onClick={() => updateStarten.mutate("pruefen")} disabled={updateStarten.isPending}>
Nach Neuem suchen
</Button>
<Button onClick={() => setFrage({ art: "alle" })} disabled={laeuft || mitUpdate.size === 0}>
Alles jetzt aktualisieren
</Button>
</div>
}
>
<p className="-mt-2 text-[15px] text-text-2">
Jeden Sonntag um 04:30 automatisch. Vorher wird gesichert, danach geprüft.
</p>
{start.isPending ? (
<Zustandsfeld text="Updates werden gelesen …" />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[680px] border-collapse">
<thead>
<tr className="schild text-left text-xs text-text-3">
<th scope="col" className="pb-2 font-semibold">Baustein</th>
<th scope="col" className="pb-2 font-semibold">Läuft</th>
<th scope="col" className="pb-2 font-semibold">Neu</th>
<th scope="col" className="pb-2 font-semibold">Was sich ändert</th>
<th scope="col" className="pb-2 text-right font-semibold"><span className="sr-only">Aktion</span></th>
</tr>
</thead>
<tbody>
{BAUSTEINE.map((b) => (
<BausteinZeile
key={b.id}
id={b.id}
name={b.name}
hatUpdate={mitUpdate.has(b.id)}
laeuftGerade={laeuft}
onStart={() => updateStarten.mutate(b.id)}
/>
))}
</tbody>
</table>
</div>
)}
{wartungsJobs.length > 0 && (
<div className="flex flex-col gap-2.5">
<h3 className="schild text-sm text-text-3">Laufende und letzte Aufträge</h3>
{wartungsJobs.slice(-3).reverse().map((j) => <JobKasten key={j.id} job={j} />)}
</div>
)}
</Panel>
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<Panel titel="Verlauf">
{verlauf.data?.laeufe.length ? (
<ol className="m-0 flex list-none flex-col p-0">
{verlauf.data.laeufe.map((l) => {
const ok = l.status === "completed"
const art = ok ? "gruen" : l.status === "failed" ? "rot" : "cyan"
return (
<li key={`${l.start}-${l.job}`} className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-start gap-3 border-t border-linie py-3">
<span className="ziffern text-sm text-text-3">{l.start ? flugplanZeit(l.start) : "–"}</span>
<span className="min-w-0 text-[15px]">
{l.job}
{l.fehler && <span className="block text-sm text-rot-text">{l.fehler}</span>}
</span>
<Chip art={art}>{ok ? "Durchgelaufen" : l.status === "failed" ? "Fehlgeschlagen" : "Läuft"}</Chip>
</li>
)
})}
</ol>
) : (
<p className="text-[15px] text-text-2">
{verlauf.isPending ? "Wird gelesen …" : "Noch keine Update-Läufe aufgezeichnet."}
</p>
)}
</Panel>
<Panel
titel="Sicherungen"
rechts={
<Button variant="outline" size="sm" onClick={() => sichern.mutate(undefined)} disabled={sichern.isPending}>
Jetzt sichern
</Button>
}
>
<p className="-mt-2 text-[15px] text-text-2">Täglich um 03:35, mit Kopie auf einem zweiten Gerät.</p>
<ul className="m-0 flex list-none flex-col p-0">
{(sicherungen.data?.backups ?? []).slice(0, 5).map((s) => (
<li key={s.file} className="flex items-center justify-between gap-3 border-t border-linie py-2.5">
<span className="ziffern text-sm">{sicherungsZeit(s)}</span>
<span className="flex items-center gap-3">
<span className="ziffern text-xs text-text-3">{s.size_mb.toLocaleString("de-DE")} MB</span>
<Button
variant="ghost"
size="sm"
disabled={!sicherungen.data?.available}
onClick={() => setFrage({ art: "zurueck", datei: s.file, zeit: sicherungsZeit(s) })}
>
Zurückspielen
</Button>
</span>
</li>
))}
</ul>
</Panel>
</div>
<Bestaetigen
offen={frage?.art === "alle"}
titel="Alles jetzt aktualisieren?"
text="Betriebssystem, Motor, llama-swap und Hermes werden nacheinander aktualisiert. Vorher wird gesichert, danach geprüft. Lucy ist dabei ein paar Minuten nicht erreichbar."
knopf="Aktualisieren"
onNein={() => setFrage(null)}
onJa={() => {
updateStarten.mutate("alle")
setFrage(null)
}}
/>
<Bestaetigen
offen={frage?.art === "zurueck"}
gefahr
titel="Sicherung zurückspielen?"
text={`Die Einstellungen von Hermes und llama-swap werden auf den Stand von ${frage?.art === "zurueck" ? frage.zeit : ""} gesetzt. Vorher wird der jetzige Stand gesichert. Die Dienste starten danach neu.`}
knopf="Zurückspielen"
onNein={() => setFrage(null)}
onJa={() => {
if (frage?.art === "zurueck") zurueck.mutate(frage.datei)
setFrage(null)
}}
/>
</>
)
}
-291
View File
@@ -1,291 +0,0 @@
import { useEffect, useMemo, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react"
import { api, type WissenDatei, type WissenFile } from "@/lib/api"
import { useWissen, useMemoryGraph } from "@/lib/queries"
import { GraphView } from "./GraphView"
import { GraphErrorBoundary } from "./GraphErrorBoundary"
import { cn } from "@/lib/utils"
// Wissens-Vault: die nächtlichen Traum-Notizen der Box als klickbares Wiki.
// [[wiki-links]] springen zur verlinkten Notiz; Markdown wird leichtgewichtig gerendert
// (bewusst ohne externe Markdown-Lib — Überschriften, Listen, fett/kursiv, Code reichen hier).
const DIR_LABEL: Record<string, string> = {
"": "Allgemein",
traeume: "Träume",
muster: "Muster",
"skill-kandidaten": "Skill-Kandidaten",
"skill-kandidaten/beauftragt": "Skill-Kandidaten · beauftragt",
"skill-kandidaten/verworfen": "Skill-Kandidaten · verworfen",
}
export function WissenView() {
const { data, isLoading } = useWissen()
const [active, setActive] = useState<string | null>(null)
const [doc, setDoc] = useState<WissenDatei | null>(null)
const [docLoading, setDocLoading] = useState(false)
// v3-Umbau P6: Das Suchfeld gab es schon, aber nur lokal. Jetzt steht das Wort in
// der URL (/wissen?suche=…) — so landet der „Finden"-Eintrag der Palette hier, und
// ein Suchergebnis ist teilbar.
const navigate = useNavigate()
const { suche = "" } = useSearch({ from: "/wissen" })
const q = suche
const setQ = (wert: string) =>
navigate({ to: "/wissen", search: (a) => ({ ...a, suche: wert || undefined }), replace: true })
const [showGraph, setShowGraph] = useState(false)
const { data: graphData, isLoading: graphLoading } = useMemoryGraph(showGraph)
const files = useMemo(() => data?.files ?? [], [data])
// Name → Pfad für [[wiki-links]] (Dateiname ohne .md, case-insensitive).
const byName = useMemo(() => {
const m = new Map<string, string>()
for (const f of files) m.set(f.name.toLowerCase(), f.path)
return m
}, [files])
const filtered = useMemo(() => {
if (!q.trim()) return files
const ql = q.toLowerCase()
return files.filter((f) => f.title.toLowerCase().includes(ql) || f.path.toLowerCase().includes(ql))
}, [files, q])
const groups = useMemo(() => {
const g: { dir: string; files: WissenFile[] }[] = []
for (const f of filtered) {
const found = g.find((x) => x.dir === f.dir)
if (found) found.files.push(f)
else g.push({ dir: f.dir, files: [f] })
}
// INDEX zuerst, dann Träume, dann Rest alphabetisch
return g.sort((a, b) => (a.dir === "" ? -1 : b.dir === "" ? 1 : a.dir.localeCompare(b.dir)))
}, [filtered])
// Beim ersten Laden: INDEX.md öffnen, falls vorhanden.
useEffect(() => {
if (!active && files.length) {
const index = files.find((f) => f.path.toLowerCase() === "index.md")
setActive(index?.path ?? files[0].path)
}
}, [files, active])
useEffect(() => {
if (!active) return
setDocLoading(true)
api<WissenDatei>(`/api/wissen/datei?pfad=${encodeURIComponent(active)}`)
.then(setDoc)
.catch(() => setDoc({ path: active, content: "*(Notiz nicht ladbar)*", mtime: 0 }))
.finally(() => setDocLoading(false))
}, [active])
const openWikiLink = (name: string) => {
const path = byName.get(name.toLowerCase())
if (path) setActive(path)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<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">
Wissen
</h1>
<p className="text-sm text-muted-foreground">
Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki.
</p>
</div>
{files.length > 0 && (
<div className="flex rounded-lg border border-border/60 bg-card p-1">
<button
onClick={() => setShowGraph(false)}
className={cn("rounded-md px-3 py-1.5 text-xs font-medium transition-colors", !showGraph ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:bg-muted")}
>
Liste
</button>
<button
onClick={() => setShowGraph(true)}
className={cn("rounded-md px-3 py-1.5 text-xs font-medium transition-colors", showGraph ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:bg-muted")}
>
Graph
</button>
</div>
)}
</div>
{data && !data.available && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar.
</div>
)}
{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" /> Vault wird geladen …
</div>
)}
{data?.available && files.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste.
</div>
)}
{showGraph ? (
<div className="mc-card flex h-[70vh] flex-col overflow-hidden p-0 relative">
{graphLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card p-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Graph wird berechnet …
</div>
</div>
)}
<GraphErrorBoundary>
{graphData && (
<GraphView
data={graphData}
selectedNodeId={active}
onNodeSelect={(id) => {
if (id) {
setActive(id)
setShowGraph(false)
}
}}
/>
)}
</GraphErrorBoundary>
</div>
) : files.length > 0 && (
<div className="flex flex-col gap-4 lg:flex-row">
{/* Datei-Liste */}
<aside className="w-full shrink-0 space-y-3 lg:w-72">
<div className="relative">
<input value={q} onChange={(e) => setQ(e.target.value)} type="search"
placeholder="Notizen durchsuchen…" aria-label="Notizen durchsuchen"
className="h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50" />
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin">
{groups.map(({ dir, files: gf }) => (
<div key={dir || "__root"}>
<p className="mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
<FolderOpen className="h-3 w-3" /> {DIR_LABEL[dir] ?? dir}
</p>
<div className="space-y-0.5">
{gf.map((f) => (
<button key={f.path} onClick={() => setActive(f.path)}
className={cn("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",
active === f.path ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-foreground")}>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate" title={f.title}>{f.name}</span>
{f.neu && <span className="ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300"><Sparkles className="h-2.5 w-2.5" />neu</span>}
</button>
))}
</div>
</div>
))}
</div>
</aside>
{/* Inhalt */}
<div className="min-w-0 flex-1 mc-card p-5">
{docLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /> Notiz wird geladen …</div>
) : doc ? (
<>
<div className="mb-3 flex items-center justify-between border-b border-border/40 pb-2">
<span className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground"><Library className="h-3.5 w-3.5" /> {doc.path}</span>
{doc.mtime > 0 && (
<span className="text-[10px] text-muted-foreground/60">
Stand {new Date(doc.mtime * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
)}
</div>
<Markdown text={doc.content} onWikiLink={openWikiLink} known={byName} />
</>
) : null}
</div>
</div>
)}
</div>
)
}
// ── Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX) ──
function Markdown({ text, onWikiLink, known }: {
text: string
onWikiLink: (name: string) => void
known: Map<string, 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-lg font-bold mt-1 mb-3" : level === 2 ? "text-base font-bold mt-4 mb-2" : "text-sm font-bold mt-3 mb-1.5"
out.push(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], onWikiLink, known, 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], onWikiLink, known, key)}</li>)
return
}
flushList(key)
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, onWikiLink, known, 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="max-w-3xl">{out}</div>
}
// Inline: [[wiki-links]], **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
function inline(text: string, onWikiLink: (n: string) => void, known: Map<string, string>, keyBase: string): React.ReactNode[] {
const parts = text.split(/(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g)
return parts.map((p, i) => {
const key = `${keyBase}-${i}`
const wiki = p.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/)
if (wiki) {
const target = wiki[1].trim()
const label = (wiki[2] ?? wiki[1]).trim()
const exists = known.has(target.toLowerCase())
return exists ? (
<button key={key} onClick={() => onWikiLink(target)}
className="rounded bg-primary/10 px-1 text-primary underline decoration-primary/40 underline-offset-2 hover:bg-primary/20 cursor-pointer">{label}</button>
) : (
<span key={key} className="rounded bg-background/40 px-1 text-muted-foreground" title="Notiz existiert (noch) nicht">{label}</span>
)
}
if (p.startsWith("**") && p.endsWith("**")) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
if (p.startsWith("*") && p.endsWith("*") && p.length > 2) return <i key={key}>{p.slice(1, -1)}</i>
if (p.startsWith("`") && p.endsWith("`")) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-teal-300">{p.slice(1, -1)}</code>
return <span key={key}>{p}</span>
})
}
@@ -1,68 +0,0 @@
import { ChevronRight, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
export type TileTone = "ok" | "warn" | "alert" | "muted" | "loading"
const DOT: Record<TileTone, string> = {
ok: "bg-emerald-500",
warn: "bg-amber-500",
alert: "bg-red-500",
muted: "bg-muted-foreground/40",
loading: "bg-muted-foreground/40 animate-pulse",
}
const RING: Record<TileTone, string> = {
ok: "hover:border-emerald-500/40",
warn: "hover:border-amber-500/50",
alert: "hover:border-red-500/50",
muted: "hover:border-primary/40",
loading: "hover:border-border/60",
}
// Eine Bereichs-Kachel der Cockpit-Startseite: EIN Symbol, EINE große Live-Zahl,
// EIN Status-Punkt, Klartext-Titel. Tiefe steckt hinter dem Klick (Werkbank/Drawer).
export function CockpitTile({
icon: Icon,
title,
value,
unit,
tone = "muted",
hint,
onClick,
}: {
icon: LucideIcon
title: string
value: React.ReactNode
unit?: string
tone?: TileTone
hint?: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"group relative flex flex-col items-start mc-card p-5 text-left cursor-pointer",
RING[tone],
)}
>
<div className="mb-4 flex w-full items-center justify-between">
<span className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/40 bg-background/30 text-foreground/80 transition-colors group-hover:text-primary">
<Icon className="h-5 w-5" />
</span>
<span className={cn("h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[tone])} title={hint} />
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl font-bold tracking-tight text-foreground font-space tabular-nums">{value}</span>
{unit && <span className="text-xs font-medium text-muted-foreground">{unit}</span>}
</div>
<div className="mt-1 flex w-full items-center justify-between">
<span className="text-sm font-semibold text-foreground/90">{title}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 transition-all group-hover:translate-x-0.5 group-hover:text-primary" />
</div>
{hint && <span className="mt-0.5 text-[11px] text-muted-foreground">{hint}</span>}
</button>
)
}
-428
View File
@@ -1,428 +0,0 @@
import { useState } from "react"
import {
Boxes, Server, ShieldAlert, Plug,
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
Lightbulb, HelpCircle,
} from "lucide-react"
import { useNavigate } from "@tanstack/react-router"
import { api } from "@/lib/api"
import {
useModels, useServices, useUpdates, useAgentStatus, useConnectHealth,
useIdeen, useQueryClient, qk,
} from "@/lib/queries"
import { useLucyHealth, type Repair } from "@/lib/useLucyHealth"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { CockpitTile } from "./CockpitTile"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { LatencyCard } from "@/components/dashboard/LatencyCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
// Öffnet die System-Schublade — v3-Umbau P3: über die URL (?system=), nicht mehr über
// einen globalen CustomEvent. Der Zustand ist damit teilbar und überlebt einen Reload;
// vorher war er ein useState in der App, das man nur von außen anstoßen konnte.
function useSchublade() {
const navigate = useNavigate()
return (tab: "wartung" | "logs") =>
navigate({ to: ".", search: (a) => ({ ...a, system: tab }) })
}
// Cockpit = die Startseite. Ruhige Übersicht: ehrliche Status-Zeile oben, „Braucht
// dich"-Bereich für offene Aktionen, darunter die Bereichs-Kacheln (je EINE Live-Zahl).
// Überblick außen, Tiefe hinter der Kachel — Daten/Aktionen aus den IST-Hooks.
export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void }) {
const openDrawer = useSchublade()
const { data: models } = useModels()
const { data: svc } = useServices()
const { data: updates } = useUpdates()
const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth()
const { data: ideen } = useIdeen()
const health = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const [busy, setBusy] = useState<Record<string, boolean>>({})
async function runRepair(id: string, repair: Repair) {
setBusy((b) => ({ ...b, [id]: true }))
try {
if (repair.kind === "loadModel") {
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
} else {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service: repair.service }),
})
if (!r.ok) showAlert("Reparatur nicht ganz geklappt", (r.err || "Unbekannter Fehler") +
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""))
}
qc.invalidateQueries({ queryKey: qk.health })
qc.invalidateQueries({ queryKey: qk.services })
qc.invalidateQueries({ queryKey: qk.models })
} catch (e: any) {
showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [id]: false }))
}
}
// ── Kachel-Zahlen aus den IST-Daten ableiten ──────────────────────────────
const running = models?.running?.length ?? 0
const svcOk = svc?.services.filter((s) => s.ok).length ?? 0
const svcTotal = svc?.services.length ?? 0
const svcErrors = svcTotal - svcOk
const pendingCount =
(updates ? (updates.os > 0 ? 1 : 0) + (updates.engine > 0 ? 1 : 0) + (updates.swap > 0 ? 1 : 0) + (updates.models > 0 ? 1 : 0) : 0) +
(updates?.components?.filter((c) => c.update === true).length ?? 0)
// Hängende Ideen warten auf DEINE Antwort — das Cockpit muss sie zeigen (Review 15.07.).
const openIdeen = ideen?.available ? ideen.items.filter((i) => i.status !== "done").length : 0
const blockedIdeen = ideen?.available ? ideen.items.filter((i) => i.status === "blocked").length : 0
// Verbinden: die drei Leitungen (Modell-Gateway · Gedächtnis · Desktop-Gateway).
const connectLines = connect ? [connect.gateway, connect.memory, connect.desktop_gateway] : []
const connectOkCount = connectLines.filter((l) => l?.ok).length
const connectBad = connect
? (!connect.gateway.ok ? "Modell-Leitung" : !connect.memory.ok ? "Gedächtnis-Leitung" : !connect.desktop_gateway.ok ? "Desktop-Gateway" : "")
: ""
// Zusätzliche reaktive Livedaten für die Kacheln
const activeBrain = models?.models?.find((m: any) => m.role === "hermes")
const brainName = activeBrain?.name ? activeBrain.name.split("/").pop()?.replace(/\.gguf$/i, "") : ""
const failedSvc = svc?.services.filter((s: any) => !s.ok).map((s: any) => s.name).join(", ")
const upParts = updates ? [
updates.os > 0 && "OS",
updates.engine > 0 && "Engine",
updates.swap > 0 && "Swap",
updates.models > 0 && "Modelle"
].filter(Boolean) : []
const updateSummary = upParts.length > 0 ? upParts.join(" + ") : ""
return (
<div className="space-y-6">
{/* Kopf */}
<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">
Cockpit
</h1>
<p className="text-sm text-muted-foreground">Deine Box auf einen Blick — Details hinter jeder Kachel.</p>
</div>
{/* Main Grid: 2/3 Links (Inhalte & Performance), 1/3 Rechts (Shortcuts & Kalender) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
{/* Hauptspalte Links */}
<div className="lg:col-span-2 space-y-6">
{/* Status-Zeile */}
<StatusLine verdict={health.verdict} memory={health.memory} warns={health.warns} problems={health.problems} />
{/* Leistung — die Live-Graphen */}
<section>
<ZoneLabel>Leistung</ZoneLabel>
<div className="grid gap-4 md:grid-cols-1">
<SystemStatusCard />
</div>
<div className="mt-4">
<LatencyCard />
</div>
<div className="mt-4">
<TokenPerformanceCard />
</div>
</section>
{/* Bereichs-Kacheln (6 statt 7 - Logs ist in die Werkzeug-Liste gewandert) */}
<section>
<ZoneLabel>Bereiche</ZoneLabel>
<div className="grid gap-4 grid-cols-2 md:grid-cols-3">
<CockpitTile
icon={Lightbulb} title="Ideen" value={ideen?.available ? openIdeen : "—"}
unit="offen"
tone={!ideen ? "loading" : blockedIdeen > 0 ? "warn" : "ok"}
hint={blockedIdeen > 0
? `${blockedIdeen} Idee${blockedIdeen === 1 ? "" : "n"} wartet auf deine Antwort`
: openIdeen > 0 ? `${openIdeen} in Arbeit oder wartend` : "Nichts offen"}
onClick={() => onNavigate("ideen")}
/>
<CockpitTile
icon={Boxes} title="Modelle" value={running} unit="warm"
tone={running > 0 ? "ok" : "muted"}
hint={brainName ? `Hirn: ${brainName}` : "Kein Hermes-Modell geladen"}
onClick={() => onNavigate("models")}
/>
<CockpitTile
icon={Server} title="Dienste" value={svc ? `${svcOk}/${svcTotal}` : "…"} unit="laufen"
tone={!svc ? "loading" : svcErrors > 0 ? "warn" : "ok"}
hint={failedSvc ? `Ausfall: ${failedSvc}` : "Alle Dienste online"}
onClick={() => openDrawer("wartung")}
/>
<CockpitTile
icon={ShieldAlert} title="Updates" value={updates ? (pendingCount > 0 ? pendingCount : "0") : "…"}
unit={pendingCount > 0 ? "bereit" : "aktuell"}
tone={!updates ? "loading" : pendingCount > 0 ? "warn" : "ok"}
hint={pendingCount > 0 ? `Verfügbar: ${updateSummary}` : "Alles auf dem neuesten Stand"}
onClick={() => openDrawer("wartung")}
/>
<CockpitTile
icon={Plug} title="Verbinden" value={connect ? `${connectOkCount}/${connectLines.length}` : "…"}
unit="Leitungen"
tone={!connect ? "loading" : connectOkCount === connectLines.length ? "ok" : "warn"}
hint={connectBad ? `Gestört: ${connectBad}` : "Hermes Desktop & IDEs bereit"}
onClick={() => onNavigate("connect")}
/>
</div>
</section>
</div>
{/* Seitenspalte Rechts */}
<div className="space-y-6">
{/* Handlungsbedarf (NeedsYou) */}
<NeedsYou
problems={health.problems}
pendingCount={pendingCount}
blockedIdeen={blockedIdeen}
busy={busy}
onRepair={runRepair}
onNavigate={onNavigate}
/>
{/* Werkzeuge & Shortcuts */}
<section>
<ZoneLabel>Werkzeuge & Diagnose</ZoneLabel>
<ToolListCard agent={agent} svcErrors={svcErrors} onNavigate={onNavigate} />
</section>
</div>
</div>
{dialogElement}
</div>
)
}
function ZoneLabel({ children }: { children: React.ReactNode }) {
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
}
// Ehrliche Status-Zeile: großes Verdikt + Speicher-Pille. Erzählrahmen = Box.
function StatusLine({ verdict, memory, warns, problems }: {
verdict: ReturnType<typeof useLucyHealth>["verdict"]
memory: ReturnType<typeof useLucyHealth>["memory"]
warns: ReturnType<typeof useLucyHealth>["warns"]
problems: ReturnType<typeof useLucyHealth>["problems"]
}) {
const warnText = warns.length
? `${warns[0].label}: ${warns[0].detail}${warns.length > 1 ? ` (+${warns.length - 1} weitere)` : ""}`
: memory.status === "full" ? memory.text : "Nichts Schlimmes — nur ein Hinweis."
const problemText = problems.length
? `${problems[0].label}: ${problems[0].detail}${problems.length > 1 ? ` (+${problems.length - 1} weitere)` : ""}`
: "Ein wichtiger Dienst hakt."
const b = {
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: "Box wird geprüft …", sub: "Einen Moment." },
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: "Box gesund", sub: "Alle wichtigen Dienste laufen." },
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: "Kleinigkeit an der Box", sub: warnText },
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: "Box braucht Hilfe", sub: problemText },
}[verdict]
const Icon = b.icon
const memTone = { ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground" }[memory.status]
const memBar = { ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40" }[memory.status]
return (
<div className={cn("flex flex-col gap-4 rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors sm:flex-row sm:items-center sm:justify-between h-full", b.ring)}>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border/40 bg-background/30">
<Icon className={cn("h-6 w-6", b.iconCls)} />
</div>
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight text-foreground">{b.title}</h2>
<p className="text-xs text-muted-foreground">{b.sub}</p>
</div>
</div>
{/* Speicher-Pille */}
<div className="w-full max-w-[16rem] rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" /> Speicher
</span>
<span className={cn("font-mono font-bold", memTone)}>
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(1)} / ${memory.totalGb.toFixed(1)} 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", memBar)} style={{ width: `${memory.pct}%` }} />
</div>
</div>
</div>
)
}
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates
// + hängende Ideen.
function NeedsYou({
problems, pendingCount, blockedIdeen, busy, onRepair, onNavigate,
}: {
problems: ReturnType<typeof useLucyHealth>["problems"]
pendingCount: number
blockedIdeen: number
busy: Record<string, boolean>
onRepair: (id: string, r: Repair) => void
onNavigate: (v: string) => void
}) {
const openDrawer = useSchublade()
const hasUpdates = pendingCount > 0
const hasBlocked = blockedIdeen > 0
const nothing = problems.length === 0 && !hasUpdates && !hasBlocked
return (
<div className="h-full flex flex-col justify-between">
{nothing ? (
<div className="flex items-center gap-3 rounded-2xl border border-emerald-500/25 bg-emerald-500/5 p-4 h-full">
<Check className="h-5 w-5 shrink-0 text-emerald-400" />
<div>
<p className="text-sm font-semibold text-foreground">Nichts zu tun</p>
<p className="text-xs text-muted-foreground">Alles läuft. Du musst gerade nichts abnicken.</p>
</div>
</div>
) : (
<div className="space-y-2 h-full flex flex-col justify-center">
{problems.map((c) => (
<div key={c.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-2xl border border-red-500/25 bg-red-500/5 p-4">
<div className="flex min-w-0 items-center gap-3 w-full">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-red-500/30 bg-background/30 text-red-300">
<c.icon className="h-4.5 w-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-foreground">{c.label}</p>
<p className="truncate text-xs text-muted-foreground">{c.detail}</p>
</div>
</div>
{c.repair && (
<button
onClick={() => c.repair && onRepair(c.id, c.repair)}
disabled={!!busy[c.id]}
className="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border border-red-500/40 bg-red-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300 transition-all hover:bg-red-500/20 cursor-pointer disabled:opacity-60 w-full sm:w-auto justify-center"
>
{busy[c.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
{c.repair.label}
</button>
)}
</div>
))}
{hasBlocked && (
<button
onClick={() => onNavigate("ideen")}
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-red-500/30 bg-red-500/5 p-4 text-left transition-all hover:bg-red-500/10 cursor-pointer"
>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-red-500/30 bg-background/30 text-red-300">
<HelpCircle className="h-4.5 w-4.5" />
</span>
<div>
<p className="text-sm font-bold text-foreground">{blockedIdeen} Karte{blockedIdeen === 1 ? "" : "n"} braucht deine Antwort</p>
<p className="text-xs text-muted-foreground">Die Box hängt an einer Frage und wartet auf dich.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-red-300/70" />
</button>
)}
{hasUpdates && (
<button
onClick={() => openDrawer("wartung")}
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-left transition-all hover:bg-amber-500/10 cursor-pointer"
>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-amber-500/30 bg-background/30 text-amber-300">
<ShieldAlert className="h-4.5 w-4.5" />
</span>
<div>
<p className="text-sm font-bold text-foreground">{pendingCount} Update{pendingCount === 1 ? "" : "s"} bereit</p>
<p className="text-xs text-muted-foreground">Ansehen und entscheiden.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-amber-300/70" />
</button>
)}
</div>
)}
</div>
)
}
function ToolListCard({ agent, svcErrors, onNavigate }: {
agent: any
svcErrors: number
onNavigate: (v: string) => void
}) {
const openDrawer = useSchublade()
const tools = [
{
label: "Hermes Agent",
status: agent ? (agent.gateway_reachable ? "Bereit" : "Offline") : "…",
tone: !agent ? "loading" : agent.gateway_reachable ? "ok" : "alert",
action: () => onNavigate("agent"),
},
{
// EIN Eintrag für die Konsole (vorher standen "Direkte Box-Shell (SSH)" UND
// "Interaktives Terminal" in der Liste — zwei Namen, dasselbe Ziel).
label: "Konsole (Box-Shell)",
status: agent ? (agent.box_console_reachable ? "Bereit" : "Offline") : "…",
tone: !agent ? "loading" : agent.box_console_reachable ? "ok" : "warn",
action: () => onNavigate("konsole"),
},
{
label: "Systemlogs & Diagnose",
status: svcErrors > 0 ? `${svcErrors} Fehler` : "Keine Fehler",
tone: svcErrors > 0 ? "alert" : "ok",
action: () => openDrawer("logs"),
},
{
label: "Wissens-Vault",
status: "Öffnen",
tone: "muted",
action: () => onNavigate("wissen"),
},
{
label: "Chronik & Zeitmaschine",
status: "Öffnen",
tone: "muted",
action: () => onNavigate("chronik"),
},
]
return (
<div className="rounded-2xl border border-border/60 bg-card/30 p-4 space-y-3 shadow-sm">
<div className="divide-y divide-border/20">
{tools.map((t, idx) => {
const dotColor = {
loading: "bg-muted-foreground/45 animate-pulse",
ok: "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]",
warn: "bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.4)]",
alert: "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]",
muted: "bg-muted-foreground/30",
}[t.tone]
return (
<button
key={idx}
onClick={t.action}
className="w-full py-2.5 px-1.5 flex items-center justify-between text-left hover:bg-card/45 rounded-lg transition-all group cursor-pointer text-xs"
>
<span className="text-muted-foreground group-hover:text-foreground transition-colors font-medium">
{t.label}
</span>
<div className="flex items-center gap-2">
<span className={cn("text-[10px] font-semibold font-mono",
t.tone === "ok" ? "text-emerald-400" :
t.tone === "warn" ? "text-amber-400" :
t.tone === "alert" ? "text-red-400" : "text-muted-foreground/60"
)}>
{t.status}
</span>
<span className={cn("h-1.5 w-1.5 rounded-full", dotColor)} />
</div>
</button>
)
})}
</div>
</div>
)
}
@@ -1,178 +0,0 @@
// Schaubild „So funktioniert dein System" für die Anleitung.
// Zwei Teile: (1) Landkarte — wer redet mit wem, (2) Kreislauf — wie die Box
// von allein arbeitet und wo DEIN Klick sitzt. Reines Inline-SVG mit den
// Design-Tokens (hsl(var(--…))), damit es automatisch zum Theme passt.
const T = {
text: "hsl(var(--foreground))",
mut: "hsl(var(--muted-foreground))",
border: "hsl(var(--border))",
card: "hsl(var(--card) / 0.9)",
bg: "hsl(var(--background) / 0.4)",
primary: "hsl(var(--primary))",
teal: "#2dd4bf",
sky: "#38bdf8",
violet: "#a78bfa",
rose: "#fb7185",
amber: "#fbbf24",
emerald: "#34d399",
indigo: "#818cf8",
}
// Kleine Bausteine ------------------------------------------------------------
function NodeBox({ x, y, w, h, color, title, sub, sub2, strong }: {
x: number; y: number; w: number; h: number; color: string
title: string; sub?: string; sub2?: string; strong?: boolean
}) {
const cx = x + w / 2
return (
<g>
<rect x={x} y={y} width={w} height={h} rx={12} fill={T.card}
stroke={color} strokeOpacity={strong ? 0.9 : 0.45} strokeWidth={strong ? 2 : 1.25} />
<rect x={x} y={y} width={w} height={3} rx={1.5} fill={color} opacity={0.8} />
<text x={cx} y={y + (sub ? 24 : h / 2 + 4)} textAnchor="middle" fill={T.text}
fontSize={12.5} fontWeight={700}>{title}</text>
{sub && (
<text x={cx} y={y + 40} textAnchor="middle" fill={T.mut} fontSize={10}>{sub}</text>
)}
{sub2 && (
<text x={cx} y={y + 53} textAnchor="middle" fill={T.mut} fontSize={10}>{sub2}</text>
)}
</g>
)
}
function Arrow({ from, to, color = T.border, dash }: {
from: [number, number]; to: [number, number]; color?: string; dash?: boolean
}) {
const [x1, y1] = from, [x2, y2] = to
const mx = (x1 + x2) / 2
return (
<g stroke={color} strokeOpacity={0.7} fill="none" strokeWidth={1.5}
strokeDasharray={dash ? "5 4" : undefined}>
<path d={`M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`} />
<path d={`M ${x2 - 6} ${y2 - 4} L ${x2} ${y2} L ${x2 - 6} ${y2 + 4}`}
fill={color} fillOpacity={0.7} stroke="none" />
</g>
)
}
// (1) Landkarte ---------------------------------------------------------------
function Landkarte() {
// Türen links, Box rechts — Pfeile: Du → Tür → Box; Box → PC-Executor (Lucy-Bau).
const tuer = [
{ y: 34, title: "Diese Oberfläche", sub: "Browser · Cockpit & Klick-Gates", color: T.teal },
{ y: 128, title: "Lucy", sub: "Stimme & Begleiterin am PC", color: T.rose },
{ y: 222, title: "Hermes Desktop", sub: "IDE für Projekte (PC + remote)", color: T.violet },
{ y: 316, title: "Telegram", sub: "unterwegs, gleiche Erinnerung", color: T.sky },
]
return (
<svg viewBox="0 0 880 432" role="img"
aria-label="Landkarte: Du erreichst die Box über vier Türen; in der Box arbeiten Mission Control, der Hermes-Agent, der Modell-Stall, das Gedächtnis und der Wissens-Vault."
style={{ width: "100%", height: "auto", fontFamily: "var(--font-sans)" }}>
{/* Du */}
<NodeBox x={16} y={150} w={120} h={92} color={T.primary} strong title="Du" sub="entscheidest —" sub2="die Box arbeitet" />
{/* PC-Executor */}
<NodeBox x={16} y={330} w={150} h={68} color={T.emerald} title="PC-Executor" sub="baut & startet Lucy" sub2="nach deiner Annahme" />
{/* Türen */}
{tuer.map((t) => (
<NodeBox key={t.title} x={196} y={t.y} w={186} h={64} color={t.color} title={t.title} sub={t.sub} />
))}
{tuer.map((t) => (
<Arrow key={t.title} from={[136, 196]} to={[196, t.y + 32]} />
))}
{/* Die Box */}
<rect x={420} y={14} width={444} height={404} rx={16} fill={T.bg} stroke={T.border} strokeOpacity={0.7} />
<text x={642} y={38} textAnchor="middle" fill={T.text} fontSize={13} fontWeight={800} letterSpacing={0.5}>
DIE BOX · 192.168.178.151
</text>
<NodeBox x={444} y={54} w={396} h={62} color={T.teal} title="Mission Control (:9001)"
sub="Cockpit · Ideen · Chronik · Wissen" />
<NodeBox x={444} y={126} w={396} h={62} color={T.rose} title="Hermes-Agent"
sub="denkt, nutzt Werkzeuge, betreibt die Werkstatt & Nacht-Crons" />
<NodeBox x={444} y={198} w={396} h={78} color={T.sky} title="Modell-Stall (llama-swap)"
sub="Hirn · Groß · Coder · Augen · Späher · Kritiker"
sub2="+ Embedding & Sortierer fürs Gedächtnis" />
<NodeBox x={444} y={286} w={190} h={56} color={T.indigo} title="Natives Gedächtnis" sub="Hermes v0.20 SQLite & Embed" />
<NodeBox x={650} y={286} w={190} h={56} color={T.amber} title="Wissens-Vault" sub="Träume & Notizen (git)" />
<NodeBox x={444} y={352} w={396} h={52} color={T.emerald} title="Selbstheilung & Backups"
sub="Health-Checks · Auto-Revert bei Rot · PBS-Sicherung" />
{/* Türen → Box */}
{tuer.map((t) => (
<Arrow key={t.title} from={[382, t.y + 32]} to={[420, t.y + 46]} />
))}
{/* Box → PC-Executor (Lucy-Annahme) */}
<Arrow from={[420, 396]} to={[166, 364]} color={T.emerald} dash />
<text x={272} y={412} textAnchor="middle" fill={T.mut} fontSize={9.5} fontStyle="italic">
Lucy-Karte angenommen → PC baut neu
</text>
</svg>
)
}
// (2) Kreislauf ---------------------------------------------------------------
function Kreislauf() {
const stationen = [
{ x: 14, color: T.amber, title: "Idee", sub: "von dir oder vom", sub2: "Nacht-Radar" },
{ x: 158, color: T.violet, title: "Ideen-Queue", sub: "Kanban sammelt,", sub2: "Specifier plant" },
{ x: 302, color: T.rose, title: "IDE am PC", sub: "Agent baut den", sub2: "Branch (Gitea)" },
{ x: 446, color: T.sky, title: "Ampel", sub: "CI prüft Build", sub2: "und Tests" },
{ x: 590, color: T.primary, title: "DEIN Merge", sub: "nur du bringst", sub2: "es auf main", strong: true },
{ x: 734, color: T.emerald, title: "Live", sub: "deploy.sh,", sub2: "Zeitmaschine = Rückweg" },
]
return (
<svg viewBox="0 0 880 246" role="img"
aria-label="Kreislauf: Idee, Ideen-Queue, IDE am PC, Ampel, dein Merge, live — Chronik und Morgenlage berichten zurück."
style={{ width: "100%", height: "auto", fontFamily: "var(--font-sans)" }}>
{stationen.map((s) => (
<NodeBox key={s.title} x={s.x} y={38} w={132} h={66} color={s.color}
title={s.title} sub={s.sub} sub2={s.sub2} strong={s.strong} />
))}
{stationen.slice(0, -1).map((s, i) => (
<Arrow key={s.title} from={[s.x + 132, 71]} to={[stationen[i + 1].x, 71]} />
))}
{/* Rückweg: Live → Idee (Bericht & Lern-Futter) */}
<g stroke={T.border} strokeOpacity={0.7} fill="none" strokeWidth={1.5} strokeDasharray="5 4">
<path d="M 800 104 C 800 150, 720 150, 440 150 C 160 150, 80 150, 80 112" />
<path d="M 76 118 L 80 108 L 84 118" fill={T.border} fillOpacity={0.7} stroke="none" />
</g>
<text x={440} y={144} textAnchor="middle" fill={T.mut} fontSize={10} fontStyle="italic">
Chronik &amp; Morgenlage berichten · Ablehnungs-Gründe werden Lern-Futter
</text>
{/* Nacht-Fahrplan */}
<rect x={14} y={176} width={852} height={54} rx={12} fill={T.bg} stroke={T.border} strokeOpacity={0.5} />
<text x={440} y={198} textAnchor="middle" fill={T.text} fontSize={11} fontWeight={700}>
Nachts, ganz allein:
</text>
<text x={440} y={216} textAnchor="middle" fill={T.mut} fontSize={10}>
03:15 Traum · 03:35 Selbst-Inventur · 03:45 Idle-Radar · 04:00 Karten-Gutachter · 04:10 Bagatellen · 04:30 Morgenlage · Mo 05:15 Release-Radar
</text>
</svg>
)
}
export function SystemSchaubild() {
return (
<div className="space-y-5">
<div className="overflow-x-auto">
<div className="min-w-[680px]"><Landkarte /></div>
</div>
<div className="border-t border-border/20 pt-4 overflow-x-auto">
<div className="min-w-[680px]"><Kreislauf /></div>
</div>
</div>
)
}
-316
View File
@@ -1,316 +0,0 @@
import { useState } from "react"
import { Download, Star, Layers, Check, Zap, Eye, Code, Brain, BrainCircuit, Compass, ChevronDown, ChevronUp, Scale, Search } from "lucide-react"
import { api } from "@/lib/api"
import { useModels, useUpdates, useDiscover } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { fmtBytes } from "@/lib/format"
import { FitBadge } from "@/components/models/ModelBadges"
import { ModelBrowse } from "./ModelBrowse"
// Die kanonischen Rollen (identisch zu sources.py / ModelBadges.ROLES).
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
fast: {
title: "Schnelles Alltags-Hirn",
desc: "Schnelle MoE-Antworten für Chat, Zusammenfassungen und alltägliche Aufgaben.",
icon: Zap
},
heavy: {
title: "Schweres Reasoning",
desc: "Große Modelle für komplexe Logik, Mathematik und tiefgründiges Planen.",
icon: Brain
},
coder: {
title: "Coden & Entwicklung",
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
icon: Code
},
vision: {
title: "Bilder & Vision",
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
icon: Eye
},
hermes: {
title: "Lucys Hirn (Agent)",
desc: "Lucys dauer-warmes Agent-Hirn mit Tool-Calling — bleibt ko-resident, lädt nie kalt nach.",
icon: BrainCircuit
},
scout: {
title: "Multimodal-Allrounder",
desc: "Multimodaler Allrounder für schnelle Antworten, Übersetzungen und Alltag.",
icon: Compass
},
kritiker: {
title: "Kritisches Prüf-Hirn",
desc: "Schärfer Blick für Fehler, Schwachstellen und Sicherheitslücken — denkt skeptisch, bevor du deployst.",
icon: Scale
},
reranker: {
title: "Gedächtnis-Sortierer",
desc: "Sortiert und gewichtet gespeicherte Fakten nach Relevanz — damit die richtige Info sofort da ist.",
icon: Search
},
debugger: {
title: "Debugger & Selbstheiler",
desc: "Agentisches Debugging, multimodale Fehleranalyse und Selbstreparatur (z.B. Muse-Glimmer).",
icon: Brain
}
}
export function Discover() {
const { data, isLoading: loading, error: loadErr } = useDiscover()
const { data: modelsResp } = useModels()
const { data: updates } = useUpdates()
const models = modelsResp?.models ?? []
const error = loadErr ? String(loadErr) : ""
const [installing, setInstalling] = useState<Record<string, string>>({})
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
const [mode, setMode] = useState<"recommended" | "browse">("recommended")
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
})
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
} catch (e) {
// Seit P1 traegt api() den echten Grund vom Backend (ApiError.detail) — den zeigen,
// statt ihn zu verschlucken und "Fehler" hinzuschreiben.
setInstalling((s) => ({ ...s, [repo]: `Fehler: ${e instanceof Error ? e.message : String(e)}` }))
}
}
return (
<div className="space-y-6">
{/* Modus-Umschalter: geführt (Empfohlen) vs. Stöbern & Suchen */}
<div className="flex mc-card-sm p-1 w-fit">
{([["recommended", "Empfohlen"], ["browse", "Stöbern & Suchen"]] as const).map(([m, label]) => (
<button
key={m}
onClick={() => setMode(m)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide transition-all cursor-pointer",
mode === m ? "bg-primary text-primary-foreground shadow-md shadow-primary/10" : "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
))}
</div>
{mode === "browse" ? (
<ModelBrowse />
) : loading ? (
<div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen…</div>
) : (error || !data) ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Empfehlungsdienst temporär nicht erreichbar ({error}).
</div>
) : (
<div className="space-y-8">
{/* Informational Header */}
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-4 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-3 shadow-sm">
<div>
Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
</div>
<div className="flex items-center gap-1.5">
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
</div>
</div>
{/* Sockets/Slots Grid */}
<div className="grid gap-6 md:grid-cols-2">
{data.categories.map((cat) => {
const meta = ROLE_METADATA[cat.role] || {
title: cat.title || cat.role,
desc: "Spezifisches Modell für diese Systemrolle.",
icon: Layers
}
const IconComponent = meta.icon
// Check if a model is installed for this role — per Rolle ODER Alias:
// ein Modell kann eine Kategorie über einen Alias bedienen (z.B. Qwen3.6 hat
// role="hermes" + Alias "fast" → sonst zeigte die Fast-Karte fälschlich „Frei").
const installedModel = models.find(
(m) => m.role === cat.role || (m.aliases || []).includes(cat.role),
)
// Check if an upgrade is available for this role
const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
// Get the primary recommended model
const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
if (!recommendedModel) return null
const isInstallingRecommended = installing[recommendedModel.repo]
const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
const isExpanded = !!expandedAlternatives[cat.role]
return (
<div
key={cat.role}
className={cn(
"mc-card p-5 flex flex-col justify-between gap-5",
installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
)}
>
<div className="space-y-4">
{/* Socket Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-sm shrink-0">
<IconComponent className="h-5.5 w-5.5" />
</div>
<div>
<h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3>
<span className="text-[9px] font-mono text-muted-foreground uppercase bg-background/50 px-1.5 py-0.5 rounded border border-border/30 inline-block mt-0.5">
Rolle: {cat.role}
</span>
</div>
</div>
{/* Status Badges */}
{installedModel ? (
<span className="flex items-center gap-1.5 text-[9px] font-bold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-lg">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Aktiviert
</span>
) : (
<span className="text-[9px] font-bold text-primary/70 uppercase tracking-wider bg-primary/10 border border-primary/20 px-2.5 py-1 rounded-lg">
Frei
</span>
)}
</div>
{/* Role Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{meta.desc}
</p>
{/* Current vs Recommended model card */}
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
{installedModel ? (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">Aktive GGUF-Belegung</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
{installedModel.name.split("/").pop()}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
<span>•</span>
<span>Quant: {installedModel.quant || "GGUF"}</span>
</div>
</div>
) : (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-primary/80">Empfohlenes Modell</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={recommendedModel.name}>
{recommendedModel.name}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
<span>Ersteller: {recommendedModel.author}</span>
<span>•</span>
<span>Quant: {recommendedModel.quant}</span>
</div>
<div className="flex items-center gap-1.5 pt-0.5">
<FitBadge fit={recommendedModel.fit} />
</div>
</div>
)}
</div>
{/* Main Action Button */}
<div className="pt-1">
{installedModel ? (
hasUpgrade ? (
<div className="space-y-2">
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
<span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
</div>
<button
// Quant des Empfehlungsmodells nur übernehmen, wenn das Upgrade DASSELBE
// Repo ist — sonst neutraler Default (fremde Quant könnte dort fehlen).
onClick={() => install(hasUpgrade.repo, cat.role,
hasUpgrade.repo === recommendedModel.repo ? (recommendedModel.quant || "Q4_K_M") : "Q4_K_M",
recommendedModel.caps.tools !== "no")}
disabled={!!installing[hasUpgrade.repo]}
className="h-8 w-full flex items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-black text-xs font-bold hover:bg-amber-400 disabled:opacity-50 transition-all cursor-pointer shadow-md shadow-amber-500/10"
>
<Download className="h-3.5 w-3.5" />
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
</button>
</div>
) : (
<div className="h-8 flex items-center justify-center gap-1.5 text-[10px] font-bold text-emerald-400 uppercase tracking-wide bg-emerald-500/5 border border-emerald-500/10 rounded-lg select-none">
<Check className="h-4 w-4" /> Auf neuestem Stand
</div>
)
) : (
<button
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!isInstallingRecommended}
className={cn(
"h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
isInstallingRecommended
? "border-primary/40 bg-primary/5 text-primary"
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
)}
>
<Download className="h-3.5 w-3.5" />
{isInstallingRecommended || "Optimales Modell einsetzen"}
</button>
)}
</div>
</div>
{/* Collapsible alternatives list */}
{alternativeModels.length > 0 && (
<div className="border-t border-border/20 pt-3">
<button
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))}
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80 hover:text-foreground transition-colors cursor-pointer"
>
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
</button>
{isExpanded && (
<div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
{alternativeModels.map((alt) => (
<div key={alt.repo} className="p-2 rounded-lg bg-background/25 border border-border/20 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
{alt.name}
</div>
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
<span>Quant: {alt.quant}</span>
<span>•</span>
<span>{alt.fit.text}</span>
</div>
</div>
<button
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
disabled={!!installing[alt.repo]}
className="h-6 px-2.5 rounded bg-background/40 hover:bg-background/80 border border-border/40 text-[9px] font-bold text-foreground transition-all cursor-pointer shrink-0 disabled:opacity-50"
>
{installing[alt.repo] || "Installieren"}
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
-314
View File
@@ -1,314 +0,0 @@
import { useEffect, useState } from "react"
import { Download, Search, AlertTriangle, ChevronDown, ChevronUp, Heart, Check, Flame } from "lucide-react"
import { api, type FitResp } from "@/lib/api"
import { useModels } from "@/lib/queries"
import { cn } from "@/lib/utils"
type Hit = { repo: string; downloads: number; likes: number }
const CHIPS: { key: string; label: string; q: string }[] = [
{ key: "popular", label: "Beliebt", q: "" },
{ key: "coder", label: "Coder", q: "coder" },
{ key: "vision", label: "Vision", q: "vision" },
{ key: "reasoning", label: "Reasoning", q: "reasoning" },
{ key: "small", label: "Klein (≤4B)", q: "3B" },
]
const ROLE_OPTS = ["fast", "heavy", "coder", "vision", "hermes", "scout"]
const _nf = new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 })
function fmtN(n: number): string {
return _nf.format(n)
}
export function ModelBrowse() {
const { data: modelsData } = useModels()
const installed = modelsData?.models ?? []
const [q, setQ] = useState("")
const [results, setResults] = useState<Hit[]>([])
const [loading, setLoading] = useState(false)
const [msg, setMsg] = useState("")
const [chip, setChip] = useState("popular")
const [direct, setDirect] = useState("")
// Pro aufgeklapptem Treffer: Quant/Rolle/Fit/Install-Zustand.
const [open, setOpen] = useState<string | null>(null)
const [quants, setQuants] = useState<string[]>([])
const [quant, setQuant] = useState("Q4_K_M")
const [role, setRole] = useState("")
const [fit, setFit] = useState<FitResp | null>(null)
const [oomArmed, setOomArmed] = useState(false)
const [installing, setInstalling] = useState<Record<string, string>>({})
async function runSearch(query: string) {
setLoading(true)
setMsg("")
try {
const d = await api<{ results: Hit[] }>(`/api/hf/search?q=${encodeURIComponent(query)}`)
setResults(d.results)
if (!d.results.length) setMsg("Keine Ergebnisse.")
} catch (e) {
setMsg(`Suche fehlgeschlagen: ${e}`)
} finally {
setLoading(false)
}
}
// Beim Öffnen: Trending laden, damit man sofort stöbern kann (ohne zu tippen).
useEffect(() => { runSearch("") }, [])
function pickChip(c: typeof CHIPS[number]) {
setChip(c.key)
setQ("")
runSearch(c.q)
}
function submitSearch() {
setChip("")
runSearch(q)
}
async function refreshFit(repo: string, qq: string, rl: string) {
setOomArmed(false)
try {
const d = await api<FitResp>(
`/api/fit?params_b=0&quant=${encodeURIComponent(qq)}&ctx=8192&name=${encodeURIComponent(repo)}&role=${encodeURIComponent(rl)}`
)
setFit(d)
} catch { setFit(null) }
}
async function toggleExpand(repo: string) {
if (open === repo) { setOpen(null); return }
setOpen(repo)
setQuants([]); setFit(null); setRole(""); setOomArmed(false)
setMsg("Analysiere Repository…")
try {
const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(repo)}`)
setQuants(d.quants)
const pick = d.quants.includes("Q4_K_M") ? "Q4_K_M" : (d.quants[0] || "Q4_K_M")
setQuant(pick)
setMsg(d.quants.length ? "" : "Keine GGUF-Dateien in diesem Repo gefunden.")
if (d.quants.length) refreshFit(d.repo, pick, "")
} catch (e) {
setMsg(`Fehler: ${e}`)
}
}
function loadDirect() {
const r = direct.trim()
if (!r) return
// Treffer-Liste mit dem Direkt-Repo vorn ergänzen + aufklappen.
setResults((rs) => rs.some((x) => x.repo === r) ? rs : [{ repo: r, downloads: 0, likes: 0 }, ...rs])
setDirect("")
toggleExpand(r)
}
// Welches Modell hält die gewählte Rolle? (exklusiv → würde übernommen)
const roleHolder = role ? installed.find((m) => (m.role || "").toLowerCase() === role) : undefined
async function install(repo: string) {
if (fit?.fit.level === "too_tight" && !oomArmed) { setOomArmed(true); return }
setInstalling((s) => ({ ...s, [repo]: "Starte…" }))
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, quant, role: role || undefined, jinja: true }),
})
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
setOomArmed(false)
} catch (e) {
// Seit P1 traegt api() den echten Grund vom Backend (ApiError.detail) — den zeigen,
// statt ihn zu verschlucken und "Fehler" hinzuschreiben.
setInstalling((s) => ({ ...s, [repo]: `Fehler: ${e instanceof Error ? e.message : String(e)}` }))
}
}
const isInstalled = (repo: string) => {
const tail = repo.split("/").pop()?.toLowerCase().replace(/-gguf$/i, "") || ""
return tail.length > 3 && installed.some((m) => m.name.toLowerCase().includes(tail))
}
const fitTone = (lvl?: string) =>
lvl === "perfect" ? "text-emerald-400 border-emerald-500/40 bg-emerald-500/10"
: lvl === "marginal" ? "text-amber-400 border-amber-500/40 bg-amber-500/10"
: "text-red-400 border-red-500/40 bg-red-500/10"
return (
<div className="space-y-5">
{/* Suchleiste — immer sichtbar */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitSearch()}
aria-label="HuggingFace durchsuchen"
type="search"
spellCheck={false}
placeholder="HuggingFace durchsuchen (z.B. Qwen Coder, Llama-3.1, gemma)…"
className="w-full h-10 pl-9 pr-3 rounded-xl border border-border/60 bg-background/40 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
</div>
<button
onClick={submitSearch}
className="h-10 px-5 rounded-xl bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer"
>
Suchen
</button>
</div>
{/* Filter-Chips zum Stöbern */}
<div className="flex items-center gap-2 flex-wrap">
{CHIPS.map((c) => (
<button
key={c.key}
onClick={() => pickChip(c)}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[11px] font-semibold transition-all cursor-pointer border",
chip === c.key
? "bg-primary/15 text-primary border-primary/30"
: "border-border/50 text-muted-foreground hover:text-foreground hover:border-border",
)}
>
{c.key === "popular" && <Flame className="h-3 w-3" />}
{c.label}
</button>
))}
</div>
{/* Treffer */}
{loading ? (
<div className="text-xs text-muted-foreground py-10 text-center">Lade GGUF-Modelle von HuggingFace…</div>
) : (
<div className="space-y-2">
{results.map((r) => {
const isOpen = open === r.repo
const inst = isInstalled(r.repo)
const author = r.repo.includes("/") ? r.repo.split("/")[0] : "—"
const name = r.repo.split("/").pop()
return (
<div
key={r.repo}
className={cn(
"mc-card-sm",
isOpen ? "border-primary/40 shadow-lg shadow-primary/5" : "border-border/50 hover:border-primary/30",
)}
>
{/* Kopf-Zeile (klickbar) */}
<button
onClick={() => toggleExpand(r.repo)}
className="w-full flex items-center gap-3 p-3.5 text-left cursor-pointer"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold font-mono text-foreground truncate max-w-[280px]" title={r.repo}>{name}</span>
{inst && (
<span className="flex items-center gap-1 text-[8px] font-bold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
<Check className="h-2.5 w-2.5" /> installiert
</span>
)}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-3 mt-0.5 font-mono">
<span>{author}</span>
<span className="flex items-center gap-1"><Download className="h-3 w-3" /> {fmtN(r.downloads)}</span>
{r.likes > 0 && <span className="flex items-center gap-1"><Heart className="h-3 w-3" /> {fmtN(r.likes)}</span>}
</div>
</div>
{isOpen ? <ChevronUp className="h-4 w-4 text-muted-foreground shrink-0" /> : <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />}
</button>
{/* Aufgeklappt: Quant + Rolle + Fit + Install */}
{isOpen && (
<div className="border-t border-border/30 p-3.5 space-y-3">
{quants.length === 0 ? (
<div className="text-[11px] text-muted-foreground font-mono">{msg || "Lade Quants…"}</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Quant</label>
<select
value={quant}
onChange={(e) => { setQuant(e.target.value); refreshFit(r.repo, e.target.value, role) }}
aria-label="Quantisierung"
className="h-8 rounded-lg border border-border/60 bg-background/40 px-2 text-xs text-foreground font-semibold outline-none"
>
{quants.map((qq) => <option key={qq} value={qq} className="bg-popover text-foreground">{qq}</option>)}
</select>
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Rolle</label>
<select
value={role}
onChange={(e) => { setRole(e.target.value); refreshFit(r.repo, quant, e.target.value) }}
aria-label="Rolle"
title="Optional — bestimmt Auto-Konfiguration + setup-bewussten Kontext"
className="h-8 rounded-lg border border-border/60 bg-background/40 px-2 text-xs text-foreground font-semibold outline-none"
>
<option value="" className="bg-popover text-foreground">keine</option>
{ROLE_OPTS.map((rl) => <option key={rl} value={rl} className="bg-popover text-foreground">{rl}</option>)}
</select>
<button
onClick={() => install(r.repo)}
disabled={!!installing[r.repo] && installing[r.repo] === "Download läuft"}
className={cn(
"h-8 px-3.5 ml-auto rounded-lg text-xs font-semibold transition-all cursor-pointer flex items-center gap-1.5",
oomArmed ? "bg-red-500 text-white hover:opacity-90" : "bg-primary text-primary-foreground hover:opacity-90",
)}
>
{installing[r.repo] ? installing[r.repo]
: oomArmed ? <><AlertTriangle className="h-3.5 w-3.5" /> OOM — trotzdem laden</>
: <><Download className="h-3.5 w-3.5" /> Herunterladen</>}
</button>
</div>
{/* Fit-Ampel */}
{fit && (
<div className={cn("flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border px-3 py-2 text-[11px] font-medium", fitTone(fit.fit.level))}>
<span className="font-bold uppercase tracking-wide">{fit.fit.text}</span>
<span className="font-mono opacity-90">~{fit.params_b}B · ~{fit.fit.req_gb} GB / {fit.sys_ram_gb} GB · ~{fit.fit.tps} t/s</span>
{fit.fit.level !== "too_tight"
? <span className="font-mono opacity-80">ctx → {(fit.assigned_ctx / 1024).toFixed(0)}k</span>
: <span className="opacity-90">— passt nicht, würde beim Laden abstürzen (OOM).</span>}
</div>
)}
{/* Rollen-Übernahme-Warnung */}
{roleHolder && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>Rolle <strong>„{role}"</strong> hält aktuell <strong>{roleHolder.name.split("/").pop()}</strong> — wird beim Download übernommen.</span>
</div>
)}
</>
)}
</div>
)}
</div>
)
})}
{!results.length && !loading && <div className="text-xs text-muted-foreground py-10 text-center">{msg || "Keine Ergebnisse."}</div>}
</div>
)}
{/* Direkt-Eingabe (Power-User: exaktes Repo/URL) */}
<div className="border-t border-border/30 pt-4 flex flex-col sm:flex-row gap-2 items-stretch sm:items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground shrink-0">Direkt:</span>
<input
value={direct}
onChange={(e) => setDirect(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && loadDirect()}
aria-label="Repository oder URL direkt eingeben"
spellCheck={false}
placeholder="org/repo oder HF-URL (z.B. unsloth/Qwen2.5-Coder-7B-Instruct-GGUF)"
className="flex-1 h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground font-mono"
/>
<button
onClick={loadDirect}
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer whitespace-nowrap"
>
Laden
</button>
</div>
</div>
)
}
@@ -1,133 +0,0 @@
import { HardDrive } from "lucide-react"
import type { ModelInfo } from "@/lib/api"
import { fmtSize, gb } from "@/lib/format"
import { roleMeta } from "@/lib/roleMeta"
import { cn } from "@/lib/utils"
import { roleBarColor } from "./roleColors"
const B = 1024 ** 3
const short = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") ?? name
// Maschinenraum-Speicherleiste — das Signatur-Element. EIN großer Balken „Arbeitsspeicher
// · 124 GB", farbig aufgeteilt nach geladenem Modell (nach Rolle eingefärbt) + freier Rest.
// Man sieht LIVE, wohin die GB gehen. Ein Klick auf ein Segment wählt das Modell rechts.
export function MaschinenraumBar({
running, capacityBytes, realUsedBytes, selected, onSelect,
}: {
running: ModelInfo[]
capacityBytes: number
realUsedBytes: number
selected?: string | null
onSelect?: (name: string) => void
}) {
const capacity = capacityBytes > 2 * B ? capacityBytes : 124 * B
const weights = running.reduce((a, m) => a + (m.size_bytes || 0), 0)
// Ehrliche Rechnung: „frei" geht von der REALEN Belegung aus (inkl. KV-Cache), nicht
// nur von den Dateigrößen — vorher konnte die Leiste viel „frei" zeigen, während der
// Speicher praktisch voll war (Review 15.07.). Die Differenz wird als eigenes
// Betriebs-Segment (KV-Cache/Overhead) sichtbar statt verschwiegen.
const overheadBytes = Math.max(0, realUsedBytes - weights)
const freeBytes = Math.max(0, capacity - Math.max(weights, realUsedBytes))
const freePct = (freeBytes / capacity) * 100
const overheadPct = (overheadBytes / capacity) * 100
return (
<div className="mc-card p-5">
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Modell-Speicher (GPU-Pool)</div>
<div className="font-space text-xl font-bold tracking-tight text-foreground">{gb(capacity)} GB <span className="text-sm font-medium text-muted-foreground">gesamt</span></div>
</div>
</div>
<div className="text-right text-[11px] font-mono text-muted-foreground">
<div><span className="font-bold text-foreground">{gb(weights)} GB</span> Modell-Gewichte</div>
{overheadBytes > 0 && <div>{gb(overheadBytes)} GB KV-Cache & Betrieb</div>}
<div className="text-emerald-400">{gb(freeBytes)} GB frei</div>
</div>
</div>
{/* Der Balken */}
<div className="flex h-12 w-full overflow-hidden rounded-xl border border-border/40 bg-background/50 p-1">
{running.length === 0 ? (
<div className="flex w-full items-center justify-center text-[11px] italic text-muted-foreground/60">
Kein Modell geladen — Auto-Swap holt bei Anfrage automatisch das passende.
</div>
) : (
<>
{running.map((m) => {
const pct = ((m.size_bytes || 0) / capacity) * 100
const c = roleBarColor(m.role)
const isSel = selected === m.name
return (
<button
key={m.name}
onClick={() => onSelect?.(m.name)}
style={{ width: `${pct}%` }}
title={`${roleMeta(m.role).label} · ${short(m.name)} · ${fmtSize(m.size_bytes)}`}
className={cn(
"group flex h-full min-w-[2.5rem] shrink-0 flex-col justify-center gap-0.5 rounded-lg px-2 text-left text-white transition-all",
c.bar,
isSel ? "ring-2 ring-white/70" : "opacity-90 hover:opacity-100",
)}
>
<span className="truncate font-space text-[10px] font-bold leading-tight tracking-wide">
{roleMeta(m.role).short}
</span>
<span className="truncate font-mono text-[9px] leading-tight opacity-85">{fmtSize(m.size_bytes)}</span>
</button>
)
})}
{overheadPct > 1.5 && (
<div
style={{ width: `${overheadPct}%` }}
className="flex h-full min-w-[2rem] items-center justify-center rounded-lg border border-dashed border-border/60 bg-background/40 text-[9px] font-mono text-muted-foreground/70"
title={`${gb(overheadBytes)} GB KV-Cache & Betrieb (reale Belegung über den Modell-Gewichten)`}
>
KV
</div>
)}
{freePct > 1.5 && (
<div
style={{ width: `${freePct}%` }}
className="flex h-full min-w-[2rem] items-center justify-center rounded-lg text-[9px] font-mono text-muted-foreground/50"
title={`${gb(freeBytes)} GB frei`}
>
frei
</div>
)}
</>
)}
</div>
{/* Legende */}
{running.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{running.map((m) => {
const c = roleBarColor(m.role)
return (
<button
key={m.name}
onClick={() => onSelect?.(m.name)}
className="flex items-center gap-1.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
>
<span className={cn("h-2.5 w-2.5 rounded-sm", c.dot)} />
<span className={cn("font-semibold", c.text)}>{roleMeta(m.role).short}</span>
<span className="font-mono text-muted-foreground/70">{short(m.name)}</span>
</button>
)
})}
{overheadBytes > 0 && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60" title="Reale Belegung über den Modell-Gewichten (Kontext-Speicher der laufenden Modelle)">
<span className="h-2.5 w-2.5 rounded-sm border border-dashed border-border/60 bg-background/40" /> KV-Cache & Betrieb · {gb(overheadBytes)} GB
</span>
)}
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60">
<span className="h-2.5 w-2.5 rounded-sm border border-border/50 bg-background/50" /> frei · {gb(freeBytes)} GB
</span>
</div>
)}
</div>
)
}
@@ -1,366 +0,0 @@
import { useMemo, useState } from "react"
import {
Search, Power, PowerOff, Edit3, Trash2, Zap, HardDrive, Cpu, Lock, Brain, Check,
} from "lucide-react"
import { api, type ModelInfo } from "@/lib/api"
import { useModels, useSystemStatus, useGroups, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { useExpertenmodus } from "@/app/store"
import { CapsChips } from "@/components/CapsChips"
import { RoleLabel, getBrandInfo } from "@/components/models/ModelBadges"
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
import { ROLE_META, roleMeta } from "@/lib/roleMeta"
import { fmtSize, fmtCtx } from "@/lib/format"
import { cn } from "@/lib/utils"
import { MaschinenraumBar } from "./MaschinenraumBar"
import { roleBarColor } from "./roleColors"
const B = 1024 ** 3
const short = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") ?? name
// Modelle-Werkbank (Frontend v3): Maschinenraum-Speicherleiste als Hero oben, darunter
// Master/Detail — „links wählen, rechts verwalten". Reine Frontend-Neuordnung; alle
// Aktionen laufen über die bestehenden /api/models-Endpoints (wie im IST-Cockpit).
// Eingebettet als Haupt-Tab im Modell-Manager (Überschrift + Discover liefert ModelsView).
export function ModelsWorkbench() {
const qc = useQueryClient()
const { data: modelsResp, isLoading, error } = useModels()
const { data: sys } = useSystemStatus()
const { data: groupsResp } = useGroups()
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
const expert = useExpertenmodus()
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const isProtected = (role?: string | null) => !!roleMeta(role).protected
const brainsGroup = groupsResp?.groups?.brains
const brainsMembers = brainsGroup?.members ?? []
const isCoresident = (name: string) => brainsMembers.includes(name)
const [selName, setSelName] = useState<string | null>(null)
const [query, setQuery] = useState("")
const [filter, setFilter] = useState<"all" | "in_use">("all")
const [specModel, setSpecModel] = useState<ModelInfo | null>(null)
const reload = () => {
qc.invalidateQueries({ queryKey: qk.models })
qc.invalidateQueries({ queryKey: qk.routing })
qc.invalidateQueries({ queryKey: qk.groups })
}
const runningInfo = models.filter((m) => running.includes(m.name))
const gttTotal = sys?.gpu?.gtt_total || sys?.gpu?.vram_total || 0
const gttUsed = sys?.gpu?.gtt_used || 0
const capacity = gttTotal > 2 * B ? gttTotal : 124 * B
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return models
.filter((m) => (filter === "in_use" ? !!m.role || running.includes(m.name) : true))
.filter((m) => (q ? m.name.toLowerCase().includes(q) : true))
.sort((a, b) => {
const aw = running.includes(a.name) ? 0 : 1
const bw = running.includes(b.name) ? 0 : 1
if (aw !== bw) return aw - bw
return short(a.name).localeCompare(short(b.name))
})
}, [models, running, filter, query])
const selected = models.find((m) => m.name === selName) ?? null
// ── Aktionen (IST-Logik) ───────────────────────────────────────────────────
async function doLoad(name: string) {
try { await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" }); reload() }
catch (e: any) { showAlert("Fehler", `Laden fehlgeschlagen: ${e.message || e}`) }
}
async function handleLoad(name: string) {
const brainsWarm = brainsMembers.some((m) => running.includes(m))
if (brainsGroup && brainsWarm && !isCoresident(name)) {
const warm = brainsMembers.filter((m) => running.includes(m)).map(short).join(", ")
showConfirm("Verdrängt das Hirn?",
`„${short(name)}" ist nicht ko-resident. Beim Laden wirft es das warme Hirn (${warm}) raus — Lucy verliert Hirn bzw. Augen.\n\nTrotzdem exklusiv laden?`,
() => doLoad(name))
return
}
void doLoad(name)
}
async function handleUnload(name: string) {
const m = models.find((x) => x.name === name)
if (m && isProtected(m.role)) {
showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig und bleibt geladen.`)
return
}
try { await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" }); reload() }
catch (e: any) { showAlert("Fehler", `Entladen fehlgeschlagen: ${e.message || e}`) }
}
async function handleRole(role: string, modelName: string) {
try {
const res = await api<{ warning?: string | null }>(`/api/models/${encodeURIComponent(modelName)}/role`, {
method: "POST", body: JSON.stringify({ role: role || null }),
})
reload()
if (res?.warning) showAlert("Rolle gesetzt — Hinweis", res.warning)
} catch (e: any) { showAlert("Fehler", `Rolle zuweisen fehlgeschlagen: ${e.message || e}`) }
}
async function handleCtx(name: string, cur: number | null) {
let auto: { ctx: number; gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string } | null = null
try { auto = await api(`/api/models/${encodeURIComponent(name)}/ctx/auto`) } catch { /* optional */ }
const hint = auto
? `Optimal für dein Setup: ${(auto.ctx / 1024).toFixed(0)}k (${auto.ctx}). »Auto« trägt diesen Wert ein.`
: "Gib die gewünschte Kontextlänge in Tokens an:"
showPrompt("Kontextlänge anpassen", hint, String(cur || 32768), async (v) => {
if (!v) return
try { await api(`/api/models/${encodeURIComponent(name)}/ctx`, { method: "POST", body: JSON.stringify({ ctx: parseInt(v, 10) }) }); reload() }
catch (e: any) { showAlert("Fehler", `Kontext setzen fehlgeschlagen: ${e.message || e}`) }
}, undefined, auto ? { autoValue: String(auto.ctx), autoLabel: `Auto (${(auto.ctx / 1024).toFixed(0)}k)` } : undefined)
}
async function handleDelete(name: string) {
const m = models.find((x) => x.name === name)
if (m && isProtected(m.role)) {
showAlert("Geschützt", `„${roleMeta(m.role).label}" kann nicht gelöscht werden. Weise die Rolle zuerst einem anderen Modell zu.`)
return
}
showConfirm("Modell löschen?", `„${short(name)}" und alle GGUF-Dateien unwiderruflich von der Box löschen?`, async () => {
try { await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" }); if (selName === name) setSelName(null); reload() }
catch (e: any) { showAlert("Fehler", `Löschen fehlgeschlagen: ${e.message || e}`) }
})
}
if (isLoading) return <div className="py-16 text-center text-xs text-muted-foreground">Werkbank wird geladen …</div>
if (error) return <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">Gateway oder Engine nicht erreichbar ({String(error)}).</div>
return (
<div className="space-y-5">
<MaschinenraumBar
running={runningInfo} capacityBytes={capacity} realUsedBytes={gttUsed}
selected={selName} onSelect={setSelName}
/>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
{/* Master — Liste */}
<div className="mc-card p-4">
<div className="mb-3 flex items-center gap-2">
<div className="relative flex-1">
<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/60" />
<input
value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Modell suchen …"
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 pl-8 pr-2 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-primary/50 focus:outline-none"
/>
</div>
<div className="flex rounded-lg border border-border/40 bg-background/30 p-0.5">
{(["all", "in_use"] as const).map((f) => (
<button key={f} onClick={() => setFilter(f)}
className={cn("rounded-md px-2 py-1 text-[9px] font-bold uppercase transition-all cursor-pointer",
filter === f ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
{f === "all" ? "Alle" : "In Benutzung"}
</button>
))}
</div>
</div>
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto scrollbar-thin pr-1">
{filtered.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/50 p-8 text-center text-xs text-muted-foreground">Keine Modelle.</div>
) : filtered.map((m) => {
const warm = running.includes(m.name)
const sel = selName === m.name
const brand = getBrandInfo(m.name)
const c = roleBarColor(m.role)
return (
<button key={m.name} onClick={() => setSelName(m.name)}
className={cn(
"flex w-full items-center gap-2.5 rounded-xl border p-2.5 text-left transition-all cursor-pointer",
sel ? "border-primary/60 bg-primary/10" : "border-border/40 bg-background/20 hover:border-primary/30 hover:bg-background/40",
)}>
<span className={cn("flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border text-[10px] font-bold", brand.color)}>{brand.initial}</span>
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs font-semibold text-foreground">{short(m.name)}</div>
<div className="mt-0.5 flex items-center gap-1.5">
{m.role && <span className={cn("h-2 w-2 rounded-sm", c.dot)} />}
<span className="text-[10px] text-muted-foreground">{m.role ? roleMeta(m.role).short : "keine Rolle"}</span>
<span className="text-[10px] text-muted-foreground/50">· {fmtSize(m.size_bytes)}</span>
</div>
</div>
{warm
? <span className="flex shrink-0 items-center gap-1 text-[9px] font-bold uppercase text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />warm</span>
: m.incomplete
? <span className="shrink-0 text-[9px] font-bold uppercase text-amber-400">⚠ fehlt</span>
: <span className="shrink-0 text-[9px] font-semibold uppercase text-muted-foreground/50">bereit</span>}
</button>
)
})}
</div>
</div>
{/* Detail — verwalten */}
<div className="mc-card p-5">
{!selected ? (
<div className="flex h-full min-h-[16rem] flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<Cpu className="h-8 w-8 opacity-40" />
<p className="text-sm font-semibold">Links ein Modell wählen</p>
<p className="text-xs">Dann verwaltest du es hier — laden, Rolle, Kontext, löschen.</p>
</div>
) : (
<Detail
m={selected} warm={running.includes(selected.name)} expert={expert}
protectedRole={isProtected(selected.role)} coresident={isCoresident(selected.name)}
onLoad={() => handleLoad(selected.name)} onUnload={() => handleUnload(selected.name)}
onRole={(r) => handleRole(r, selected.name)} onCtx={() => handleCtx(selected.name, selected.ctx)}
onSpec={() => setSpecModel(selected)} onDelete={() => handleDelete(selected.name)}
/>
)}
</div>
</div>
{specModel && <SpecDraftModal model={specModel} onClose={() => setSpecModel(null)} onChanged={reload} />}
{dialogElement}
</div>
)
}
function Detail({
m, warm, expert, protectedRole, coresident, onLoad, onUnload, onRole, onCtx, onSpec, onDelete,
}: {
m: ModelInfo; warm: boolean; expert: boolean; protectedRole: boolean; coresident: boolean
onLoad: () => void; onUnload: () => void; onRole: (r: string) => void; onCtx: () => void; onSpec: () => void; onDelete: () => void
}) {
const brand = getBrandInfo(m.name)
const lockUnload = warm && protectedRole
return (
<div className="flex h-full flex-col gap-4">
{/* Kopf */}
<div className="flex items-start gap-3">
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border text-sm font-bold", brand.color)}>{brand.initial}</span>
<div className="min-w-0 flex-1">
<h2 className="break-all font-mono text-sm font-bold text-foreground">{short(m.name)}</h2>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{m.role ? <RoleLabel role={m.role} /> : <span className="rounded border border-dashed border-border/50 px-1.5 py-0.5 text-[10px] text-muted-foreground">keine Rolle</span>}
{protectedRole && <span className="rounded bg-background/40 px-1.5 py-0.5 text-[10px] text-muted-foreground" title="Lebenswichtig — geschützt"><Lock className="mr-0.5 inline h-3 w-3" />geschützt</span>}
{coresident && <span className="rounded border border-fuchsia-500/30 bg-fuchsia-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase text-fuchsia-300"><Brain className="mr-0.5 inline h-3 w-3" />Ko-resident</span>}
{warm && <span className="flex items-center gap-1 text-[10px] font-bold uppercase text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />warm</span>}
</div>
</div>
</div>
{/* Primär-Aktion: laden / entladen */}
<button
onClick={warm ? onUnload : onLoad}
disabled={(m.incomplete && !warm) || lockUnload}
className={cn(
"flex h-11 w-full items-center justify-center gap-2 rounded-xl border text-sm font-bold transition-all cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
warm ? "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
: "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20",
)}
title={lockUnload ? `„${roleMeta(m.role).label}" bleibt geladen (geschützt).` : undefined}
>
{warm ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
{lockUnload ? "🔒 bleibt geladen" : warm ? "Entladen" : "Ins Warm-Set laden"}
</button>
{/* Rolle wählen — bei lebenswichtigen Rollen (Hirn/Gedächtnis) gesperrt: die Rolle
wandert nur, indem man sie einem ANDEREN Modell zuweist (Backend erzwingt das auch). */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<span>Rolle</span>
{protectedRole && <span className="normal-case font-medium tracking-normal text-muted-foreground/70">🔒 lebenswichtig — zum Tausch die Rolle woanders zuweisen</span>}
</div>
<div className="flex flex-wrap gap-1.5">
<RoleChip active={!m.role} onClick={() => onRole("")} dot="bg-slate-400" label="keine" disabled={protectedRole} />
{ROLE_META.map((r) => (
<RoleChip key={r.role} active={m.role === r.role} onClick={() => onRole(r.role)}
dot={roleBarColor(r.role).dot} label={r.short} disabled={protectedRole && m.role !== r.role} />
))}
</div>
</div>
{/* Kennzahlen */}
<div className="grid grid-cols-2 gap-2 text-xs">
<Metric icon={HardDrive} label="Größe" value={fmtSize(m.size_bytes)} />
<button onClick={onCtx} className="group flex items-center gap-2 rounded-lg border border-border/30 bg-background/20 p-2.5 text-left transition-colors hover:border-primary/40 cursor-pointer">
<Edit3 className="h-4 w-4 text-primary/80" />
<div className="min-w-0">
<div className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Kontext · ändern</div>
<div className="font-semibold text-foreground">{fmtCtx(m.ctx)}</div>
</div>
</button>
</div>
{/* Beschleuniger */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Beschleuniger</span>
{expert && (
<button onClick={onSpec}
className={cn("flex h-6 items-center gap-1 rounded-lg border px-2 text-[9px] font-bold uppercase transition-colors cursor-pointer",
m.spec_active ? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
: m.spec_draft_model ? "border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
: "border-border/40 text-muted-foreground hover:bg-accent")}>
<Zap className="h-3 w-3" /> Spec
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{m.spec_active && <Tag tone="emerald" label="Spec-Decoding aktiv" />}
{!m.spec_active && m.spec_draft_model && <Tag tone="amber" label="Spec inaktiv" />}
{m.prompt_cache && <Tag tone="cyan" label="Prompt-Cache" />}
{m.parallel_slots > 1 && <Tag tone="violet" label={`${m.parallel_slots} Slots`} />}
{!m.spec_active && !m.spec_draft_model && !m.prompt_cache && m.parallel_slots <= 1 && (
<span className="text-[11px] text-muted-foreground/60">keine aktiv</span>
)}
</div>
</div>
{/* Fähigkeiten */}
<div className="flex flex-wrap gap-1"><CapsChips caps={m.capabilities} /></div>
{/* Löschen */}
<button onClick={onDelete} disabled={protectedRole}
className={cn("mt-auto flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition-colors",
protectedRole ? "cursor-not-allowed border-border/30 text-muted-foreground/40"
: "border-red-500/30 text-red-400 hover:bg-red-500/10 cursor-pointer")}>
<Trash2 className="h-3.5 w-3.5" /> {protectedRole ? "geschützt — nicht löschbar" : "Modell löschen"}
</button>
</div>
)
}
function RoleChip({ active, onClick, dot, label, disabled }: {
active: boolean; onClick: () => void; dot: string; label: string; disabled?: boolean
}) {
return (
<button onClick={onClick} disabled={disabled}
title={disabled ? "Gesperrt: dieses Modell hält eine lebenswichtige Rolle." : undefined}
className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[10px] font-semibold transition-all",
disabled ? "cursor-not-allowed opacity-40 border-border/40 bg-background/20 text-muted-foreground"
: active ? "cursor-pointer border-primary/60 bg-primary/15 text-foreground"
: "cursor-pointer border-border/40 bg-background/20 text-muted-foreground hover:border-primary/30")}>
<span className={cn("h-2 w-2 rounded-sm", dot)} />
{label}
{active && <Check className="h-3 w-3 text-primary" />}
</button>
)
}
function Metric({ icon: Icon, label, value }: { icon: typeof HardDrive; label: string; value: string }) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border/30 bg-background/20 p-2.5">
<Icon className="h-4 w-4 text-primary/80" />
<div className="min-w-0">
<div className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">{label}</div>
<div className="font-semibold text-foreground">{value}</div>
</div>
</div>
)
}
function Tag({ tone, label }: { tone: "emerald" | "amber" | "cyan" | "violet"; label: string }) {
const cls = {
emerald: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
amber: "bg-amber-500/10 text-amber-400 border-amber-500/20",
cyan: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20",
violet: "bg-violet-500/10 text-violet-400 border-violet-500/20",
}[tone]
return <span className={cn("rounded border px-1.5 py-0.5 text-[10px] font-semibold", cls)}>{label}</span>
}
@@ -1,29 +0,0 @@
// Voll deckende Balken-/Punkt-Farben je Rolle — abgeleitet aus den Badge-Tönen in
// lib/roleMeta.ts, aber solide (für die Maschinenraum-Speicherleiste). Design-Sprache:
// Hirn=Indigo, Gedächtnis=Smaragd, Augen=Pink, Groß=Bernstein, Coder=Violett/Fuchsia.
export interface RoleBarColor {
bar: string // Segment-Füllung (solide)
dot: string // Legenden-Punkt
text: string // Akzent-Text
}
const BY_ROLE: Record<string, RoleBarColor> = {
hermes: { bar: "bg-indigo-500", dot: "bg-indigo-500", text: "text-indigo-400" },
embed: { bar: "bg-emerald-500", dot: "bg-emerald-500", text: "text-emerald-400" },
vision: { bar: "bg-pink-500", dot: "bg-pink-500", text: "text-pink-400" },
heavy: { bar: "bg-amber-500", dot: "bg-amber-500", text: "text-amber-400" },
"coder-lite": { bar: "bg-violet-500", dot: "bg-violet-500", text: "text-violet-400" },
coder: { bar: "bg-fuchsia-500", dot: "bg-fuchsia-500", text: "text-fuchsia-400" },
scout: { bar: "bg-teal-500", dot: "bg-teal-500", text: "text-teal-400" },
kritiker: { bar: "bg-cyan-500", dot: "bg-cyan-500", text: "text-cyan-400" },
reranker: { bar: "bg-lime-500", dot: "bg-lime-500", text: "text-lime-400" },
}
const ALIAS: Record<string, string> = { fast: "hermes", coder_lite: "coder-lite" }
const UNASSIGNED: RoleBarColor = { bar: "bg-slate-400", dot: "bg-slate-400", text: "text-slate-300" }
export function roleBarColor(role?: string | null): RoleBarColor {
if (!role) return UNASSIGNED
return BY_ROLE[role] || BY_ROLE[ALIAS[role]] || UNASSIGNED
}