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:
co-authored by
Claude Opus 5.5
parent
3d2c9549fb
commit
907289d7dc
@@ -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 & 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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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 ( /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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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 & 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 & 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 & 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 &
|
||||
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 & 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 & 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 & „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 & 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> & <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 & 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 & 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 & 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 & 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 & Skills</div>
|
||||
<ul className="list-disc pl-4 space-y-1.5">
|
||||
<LinkItem href="https://modelcontextprotocol.io" name="MCP — offizielle Doku" note="Spezifikation & Einstieg." />
|
||||
<LinkItem href="https://github.com/modelcontextprotocol/servers" name="Offizielle MCP-Server" note="filesystem, git, postgres, fetch & mehr." />
|
||||
<LinkItem href="https://smithery.ai" name="Smithery" note="Registry zum Suchen & 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 & 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 & 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 & 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 & <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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user