fix: remove Memory (Gehirn) completely from frontend widgets
Ampel / ampel (push) Successful in 22s

This commit is contained in:
Hitonabi
2026-08-07 11:05:59 +02:00
parent c2319739e2
commit d45f2fc6cb
34 changed files with 585 additions and 1303 deletions
-2
View File
@@ -15,7 +15,6 @@ import { CockpitView } from "@/views/cockpit/CockpitView"
// kommt als eigenes Chunk erst beim ersten Aufruf — kleineres Start-Bundle.
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
const MemoryView = lazy(() => import("@/views/MemoryView").then((m) => ({ default: m.MemoryView })))
const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView })))
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
@@ -206,7 +205,6 @@ export default function App() {
{view === "auftraege" && <AuftragsbuchView />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />}
{view === "wissen" && <WissenView />}
{view === "chronik" && <ChronikView />}
+1 -3
View File
@@ -1,7 +1,6 @@
import {
LayoutDashboard,
Boxes,
Brain,
Inbox,
History,
Library,
@@ -13,7 +12,7 @@ import {
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "ideen" | "auftraege" | "models" | "memory" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type ViewId = "dashboard" | "ideen" | "auftraege" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
@@ -36,7 +35,6 @@ export const NAV: NavItem[] = [
{ id: "auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
{ id: "chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain, group: "Wissen" },
{ id: "wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" },
{ id: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" },
-477
View File
@@ -1,477 +0,0 @@
import { useState, useMemo, lazy, Suspense, useEffect, useDeferredValue } from "react"
import {
Trash2, Sparkles, User, Scroll, Shield, Clock, Search, Plus, BookOpen,
Share2, List, MessagesSquare, X, Copy, Check, Terminal as TerminalIcon, Send,
Maximize2, Minimize2
} from "lucide-react"
import { api, type DedupeResult } from "@/lib/api"
import { useMemory, useMemoryGraph, useQueryClient } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { GraphErrorBoundary } from "./GraphErrorBoundary"
// Sigma/graphology nur laden, wenn der Graph-Tab geöffnet wird (Code-Splitting).
const GraphView = lazy(() => import("./GraphView").then((m) => ({ default: m.GraphView })))
const CATEGORIES = ["identity", "knowledge", "rules", "events"]
// Herkunft: automatisch gelernt (Mem0-Extraktion / Agent / Hermes) vs. manuell/Vorlage.
const AUTO_SOURCES = new Set(["auto", "agent", "hermes"])
const CAT_CONFIG: Record<string, { label: string; icon: any; bg: string; text: string }> = {
identity: { label: "Identität", icon: User, bg: "bg-cyan-500/10", text: "text-cyan-400" },
knowledge: { label: "Wissen", icon: Shield, bg: "bg-indigo-500/10", text: "text-indigo-400" },
rules: { label: "Regeln", icon: Scroll, bg: "bg-violet-500/10", text: "text-violet-400" },
events: { label: "Ereignisse", icon: Clock, bg: "bg-amber-500/10", text: "text-amber-400" },
}
const DEFAULT_CAT = { label: "Gedächtnis", icon: BookOpen, bg: "bg-muted/10", text: "text-muted-foreground" }
const BORDER_CLASSES: Record<string, string> = {
identity: "border-l-cyan-500/80", knowledge: "border-l-indigo-500/80",
rules: "border-l-violet-500/80", events: "border-l-amber-500/80",
}
// Hex-Farben für Inline-Styles (Graph-Punkte, Detail-Panel) — Tailwind-Klassen aus
// CAT_CONFIG greifen dort nicht.
const CAT_COLOR_MAP: Record<string, string> = {
identity: "#00f5ff", knowledge: "#3b82f6", rules: "#d946ef", events: "#fbbf24",
}
// Onboarding-Prompt: der Nutzer startet damit ein Gespräch mit Hermes (Terminal/Telegram).
const ONBOARDING_PROMPT = `Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu:
1) wer ich bin und woran ich gerade arbeite,
2) wie ich angesprochen werden möchte,
3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
4) wichtige Regeln/Konventionen, die du beachten sollst,
5) meine Infrastruktur (Server, Dienste ohne Geheimnisse).
Frag immer nur eine sache auf einmal und fass am Ende zusammen, was du dir gemerkt hast.`
export function MemoryView() {
const [filter, setFilter] = useState("")
const [q, setQ] = useState("")
const [content, setContent] = useState("")
const [category, setCategory] = useState("knowledge")
const [deduping, setDeduping] = useState(false)
const [copied, setCopied] = useState(false)
const [view, setView] = useState<"liste" | "graph">("liste")
const [showAdd, setShowAdd] = useState(false)
const [selected, setSelected] = useState<string | null>(null)
const [isExpanded, setIsExpanded] = useState(false)
// Nur EINE Graph-Instanz mounten: die CSS-Klassen (hidden/lg:hidden) verstecken zwar,
// aber eine per display:none versteckte ForceGraph2D-Instanz simuliert und rendert
// trotzdem weiter — das hat die CPU-Last der Ansicht glatt verdoppelt.
const [isDesktop, setIsDesktop] = useState(
() => typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches
)
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)")
const onChange = (e: MediaQueryListEvent) => setIsDesktop(e.matches)
mq.addEventListener("change", onChange)
return () => mq.removeEventListener("change", onChange)
}, [])
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const { data: allItems = [] } = useMemory({})
const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
const { data: graph } = useMemoryGraph(true)
const error = itemsErr ? String(itemsErr) : ""
// Escape-Taste schließt den Vollbildmodus
useEffect(() => {
if (!isExpanded) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsExpanded(false)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isExpanded])
const reloadMemory = () => {
qc.invalidateQueries({ queryKey: ["memory"] })
qc.invalidateQueries({ queryKey: ["memory-graph"] })
}
const stats = useMemo(() => {
const total = allItems.length
const auto = allItems.filter((m) => AUTO_SOURCES.has(m.source)).length
return { total, auto, manual: total - auto, cats: new Set(allItems.map((m) => m.category)).size }
}, [allItems])
const empty = allItems.length === 0
// Graph clientseitig nach Suchtext filtern (Knoten + deren Kanten).
// useDeferredValue: der Graph-Umbau (Re-Layout) hinkt der Tipp-Eingabe nach,
// statt bei jedem Tastendruck die D3-Simulation neu anzuwerfen.
const deferredQ = useDeferredValue(q)
const graphData = useMemo(() => {
const g = graph ?? { nodes: [], edges: [] }
if (!deferredQ.trim()) return g
const ql = deferredQ.toLowerCase()
const ns = g.nodes.filter((n) => n.content.toLowerCase().includes(ql))
const ids = new Set(ns.map((n) => n.id))
return { nodes: ns, edges: g.edges.filter((e) => ids.has(e.source) && ids.has(e.target)) }
}, [graph, deferredQ])
// Liste nach Kategorie gruppieren (skaliert: klare Sektionen statt einer flachen Wand).
const grouped = useMemo(() => {
const g: Record<string, (typeof items)[number][]> = {}
items.forEach((m) => { (g[m.category] ??= []).push(m) })
return g
}, [items])
const otherItems = useMemo(() => items.filter((m) => !CATEGORIES.includes(m.category)), [items])
async function add() {
if (!content.trim()) return
await api("/api/memory", { method: "POST", body: JSON.stringify({ content, category, source: "ui" }) })
setContent(""); setShowAdd(false); reloadMemory()
}
function del(id: string) {
showConfirm("Eintrag löschen?", "Diesen Gedächtnis-Eintrag dauerhaft entfernen?", async () => {
try { await api(`/api/memory/${id}`, { method: "DELETE" }); reloadMemory() }
catch (e: any) { showAlert("Fehler", `Löschen fehlgeschlagen: ${e.message || e}`) }
})
}
async function copyPrompt() {
try {
await navigator.clipboard.writeText(ONBOARDING_PROMPT)
setCopied(true)
setTimeout(() => setCopied(false), 1800)
} catch {
showAlert("Kopieren fehlgeschlagen", "Markiere den Prompt und kopiere ihn manuell (Strg+C).")
}
}
function goTerminal() {
copyPrompt()
// "konsole" ist die echte View-Id (nav.ts) — "terminal" existierte nie, der Knopf lief ins Leere.
window.dispatchEvent(new CustomEvent("mc-navigate", { detail: { view: "konsole" } }))
}
async function cleanup() {
setDeduping(true)
try {
const dry = await api<DedupeResult>("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: false }) })
if (dry.duplicate_count === 0) { showAlert("Ergebnis", "Keine Dubletten gefunden — alles sauber."); return }
// Konkrete Vorschau statt bloßer Zahl: was bleibt, was fliegt (erste 5 Gruppen).
const shorten = (s: string) => (s.length > 90 ? s.slice(0, 90) + "…" : s)
const preview = dry.groups.slice(0, 5).map((g) =>
`✓ bleibt: „${shorten(g.keep.content)}"\n✗ weg: ${g.remove.map((r) => `${shorten(r.content)}"`).join(", ")}`
).join("\n\n") + (dry.groups.length > 5 ? `\n\n… und ${dry.groups.length - 5} weitere Gruppe(n).` : "")
showConfirm("Deduplizierung bestätigen",
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden:\n\n${preview}\n\nZusammenführen?`,
async () => {
try { await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) }); reloadMemory() }
catch (e: any) { showAlert("Fehler", `Fehler beim Löschen: ${e.message}`) }
})
} catch (e: any) {
showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`)
} finally {
setDeduping(false)
}
}
// Suche in ALLEN Elementen, damit Klicks auf dem Graphen nicht durch lokale Filter der linken Liste blockiert werden.
const sel = useMemo(() => allItems.find((m) => m.id === selected), [allItems, selected])
const neighbors = useMemo(() => {
if (!sel || !graph) return []
const connectedIds = new Set([
...graph.edges.filter((e) => e.source === sel.id).map((e) => e.target),
...graph.edges.filter((e) => e.target === sel.id).map((e) => e.source)
])
return allItems.filter((m) => connectedIds.has(m.id))
}, [sel, graph, allItems])
return (
<div className="space-y-6">
{empty ? (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 sm:p-10 flex flex-col items-center text-center gap-5">
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center">
<MessagesSquare className="h-7 w-7 text-primary" />
</div>
<div className="space-y-1.5 max-w-lg">
<h3 className="text-base font-semibold text-foreground">Lass Hermes dich kennenlernen</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Statt Fakten einzutippen: führ ein kurzes Onboarding-Gespräch mit Hermes. Was ihr besprecht,
merkt sich das Gedächtnis automatisch du musst nichts manuell pflegen.
</p>
</div>
{/* Vorgeschlagener Prompt */}
<div className="w-full max-w-lg text-left">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">Sende das an Hermes</span>
<button onClick={copyPrompt} className="flex items-center gap-1 text-[10px] font-semibold text-muted-foreground hover:text-foreground transition-colors">
{copied ? <><Check className="h-3 w-3 text-emerald-400" /> Kopiert</> : <><Copy className="h-3 w-3" /> Kopieren</>}
</button>
</div>
<pre className="w-full rounded-xl border border-border/60 bg-background/40 p-3.5 text-[11px] leading-relaxed text-foreground/90 whitespace-pre-wrap font-mono">{ONBOARDING_PROMPT}</pre>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<button onClick={goTerminal}
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer">
<TerminalIcon className="h-4 w-4" /> Im Terminal starten
</button>
<button onClick={copyPrompt}
className="h-9 px-4 rounded-lg border border-border/60 bg-card text-xs font-semibold hover:border-primary/50 transition-all flex items-center gap-1.5 cursor-pointer">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />} Prompt kopieren
</button>
</div>
<p className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5">
<Send className="h-3 w-3" /> Funktioniert genauso über Telegram {" "}
<button onClick={() => setShowAdd(true)} className="underline hover:text-foreground">oder lieber manuell anlegen</button>.
</p>
</div>
) : (
/* Der voll integrierte, schwebende Wissens-Hub */
<div className={cn(
"bg-[#030712] overflow-hidden transition-all duration-300 ease-in-out",
isExpanded
? "fixed inset-0 z-50 w-screen h-screen"
: "relative w-full h-[calc(100vh-8.5rem)] min-h-[620px] rounded-2xl border border-border/60 flex flex-col lg:block"
)}>
{/* 1. GraphView nimmt auf Desktop 100% des Hintergrunds ein */}
{isDesktop && (
<div className="absolute inset-0 z-0 hidden lg:block">
<GraphErrorBoundary>
<Suspense fallback={<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
</Suspense>
</GraphErrorBoundary>
</div>
)}
{/* 2. Schwebendes linkes Panel (Wissens-Liste) */}
<div className={cn(
"z-10 flex flex-col transition-all duration-300",
isExpanded
? "lg:absolute lg:left-6 lg:top-6 lg:bottom-6 lg:w-96 lg:rounded-xl lg:border lg:border-border/40 lg:bg-background/65 lg:backdrop-blur-md lg:p-4 lg:shadow-2xl lg:shadow-black/50 lg:h-[calc(100%-3rem)]"
: "lg:absolute lg:left-4 lg:top-4 lg:bottom-4 lg:w-96 lg:rounded-xl lg:border lg:border-border/40 lg:bg-background/65 lg:backdrop-blur-md lg:p-4 lg:shadow-2xl lg:shadow-black/50 lg:h-[calc(100%-2rem)]",
"w-full h-full p-4 flex-1 lg:flex-none",
view === "graph" && "hidden lg:flex"
)}>
{/* Header & Steuerung direkt im Panel */}
<div className="space-y-3 mb-3 shrink-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Gedächtnis-Pool
</h1>
<p className="text-[11px] text-muted-foreground leading-relaxed mt-0.5">
IDEs &amp; Gateway lesen und lernen hier per MCP.
</p>
</div>
{/* Maximize/Minimize Button für Desktop */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 rounded-lg border border-border/40 bg-card/45 hover:border-primary/50 text-muted-foreground hover:text-foreground transition-all cursor-pointer hidden lg:block"
title={isExpanded ? "Vollbild verlassen (Esc)" : "In Vollbild maximieren"}
>
{isExpanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
{/* Statistiken (Chips) */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[9px] font-semibold bg-primary/10 text-primary border border-primary/20 px-2 py-0.5 rounded-md">{stats.total} Fakten</span>
<span className="text-[9px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded-md">{stats.auto} auto</span>
<span className="text-[9px] font-semibold bg-card/60 text-muted-foreground border border-border/40 px-2 py-0.5 rounded-md">{stats.cats} Kat.</span>
</div>
{/* Steuerungsleiste: Suchen, Eintrag, Deduplizieren */}
<div className="flex items-center gap-1.5 w-full">
<div className="relative flex-1">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
aria-label="Gedächtnis durchsuchen"
type="search"
placeholder="Semantisch suchen…"
className="w-full h-8 pl-8 pr-3 rounded-lg border border-border/50 bg-background/40 text-[11px] outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<button onClick={() => setShowAdd((s) => !s)} title="Eintrag hinzufügen"
className="h-8 w-8 shrink-0 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center justify-center cursor-pointer shadow-md shadow-primary/10">
<Plus className="h-4 w-4" />
</button>
<button onClick={cleanup} disabled={deduping} title="Deduplizieren"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-card/45 hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50">
<Sparkles className="h-3.5 w-3.5 text-primary" />
</button>
{/* Mobile View Switcher */}
<div className="flex items-center gap-1 p-0.5 bg-card/30 border border-border/40 rounded-lg lg:hidden">
{([["liste", List], ["graph", Share2]] as const).map(([v, Icon]) => (
<button key={v} onClick={() => setView(v)}
className={cn("flex h-7 w-7 items-center justify-center rounded-md transition-all cursor-pointer",
view === v ? "bg-primary text-primary-foreground" : "text-muted-foreground")}>
<Icon className="h-3.5 w-3.5" />
</button>
))}
</div>
</div>
{/* Kategorie-Filter */}
<div className="flex flex-wrap items-center gap-1 p-0.5 bg-card/30 border border-border/40 rounded-lg w-full overflow-x-auto shrink-0 scrollbar-none">
<button onClick={() => setFilter("")}
className={cn("h-6 px-2 rounded-md text-[9px] font-semibold uppercase tracking-wider transition-all cursor-pointer shrink-0",
!filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>Alle</button>
{CATEGORIES.map((c) => {
const conf = CAT_CONFIG[c] || DEFAULT_CAT
return (
<button key={c} onClick={() => setFilter(c)}
className={cn("h-6 px-2 rounded-md text-[9px] font-semibold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1 shrink-0",
filter === c ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
{conf.label}
</button>
)
})}
</div>
</div>
{/* Listenelemente (Scrollbar im Glaspanel) */}
<div className="flex-1 overflow-y-auto pr-1 scrollbar-thin space-y-4">
{showAdd && (
<div className="rounded-xl border border-border/50 bg-background/50 p-3 space-y-2 shrink-0">
<div className="flex items-center justify-between">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Neuer Eintrag</div>
<button onClick={() => setShowAdd(false)} className="text-muted-foreground hover:text-foreground"><X className="h-3.5 w-3.5" /></button>
</div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2}
placeholder="Vorliebe, Regel oder Fakt…"
className="w-full resize-none rounded-lg border border-border/50 bg-background/30 p-2 text-[11px] outline-none focus:ring-1 focus:ring-primary/50 text-foreground leading-relaxed" />
<div className="flex items-center justify-between gap-2">
<select value={category} onChange={(e) => setCategory(e.target.value)}
className="h-7 rounded-md border border-border/50 bg-background/50 px-1 text-[10px] outline-none text-foreground cursor-pointer">
{CATEGORIES.map((c) => <option key={c} value={c} className="bg-popover text-foreground">{CAT_CONFIG[c]?.label || c}</option>)}
</select>
<button onClick={add} className="h-7 px-2.5 rounded-md bg-primary text-primary-foreground text-[10px] font-semibold hover:opacity-90 transition-all cursor-pointer">
Speichern
</button>
</div>
</div>
)}
{error && <div className="rounded-lg border border-red-500/20 bg-red-500/5 p-3 text-[10px] text-red-400">Fehler: {error}</div>}
{items.length === 0 ? (
<div className="text-[11px] text-muted-foreground border border-dashed border-border/60 rounded-xl p-8 text-center bg-card/10">
Keine Einträge gefunden.
</div>
) : (
<div className="space-y-4">
{[...CATEGORIES, "__other"].map((c) => {
const list = c === "__other" ? otherItems : (grouped[c] || [])
if (!list.length) return null
const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon
return (
<div key={c} className="space-y-1.5">
<div className="flex items-center gap-1.5 px-0.5">
<Icon className={cn("h-3 w-3", conf.text)} />
<span className={cn("text-[9px] font-bold uppercase tracking-wider", conf.text)}>{conf.label}</span>
<span className="text-[9px] font-mono text-muted-foreground/60 bg-card/50 rounded px-1">{list.length}</span>
</div>
<div className="space-y-1.5">
{list.map((m) => {
const isAuto = AUTO_SOURCES.has(m.source)
const isSelected = selected === m.id
return (
<div key={m.id} onClick={() => setSelected(isSelected ? null : m.id)}
className={cn(
"flex items-start justify-between gap-3 p-2.5 rounded-lg border border-l-4 transition-all group cursor-pointer text-left",
isSelected ? "bg-primary/10 border-primary shadow-md" : "bg-card/45 border-border/40 hover:bg-card/75",
BORDER_CLASSES[m.category] || "border-l-muted"
)}>
<span className="text-[11px] text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span>
<div className="flex items-center gap-1.5 shrink-0 self-center">
{isAuto && <span title="Auto gelernt"><Sparkles className="h-2.5 w-2.5 text-emerald-400" /></span>}
<button onClick={(e) => { e.stopPropagation(); del(m.id); if (selected === m.id) setSelected(null) }}
className="h-5 w-5 rounded-md flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
{/* 3. Mobiler Fallback für den Graph (unter lg) — nur mounten, wenn wirklich sichtbar */}
{!isDesktop && view === "graph" && (
<div className="w-full h-[500px] relative lg:hidden">
<GraphErrorBoundary>
<Suspense fallback={<div className="h-full rounded-2xl border border-border/60 bg-card/30 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
</Suspense>
</GraphErrorBoundary>
</div>
)}
{/* 4. Schwebendes rechtes Panel (Details zum ausgewählten Knoten) */}
{sel && (
<div className={cn(
"z-10 flex flex-col transition-all duration-300",
isExpanded
? "lg:absolute lg:right-6 lg:top-6 lg:bottom-6 lg:w-80 lg:rounded-xl lg:border lg:border-border/40 lg:bg-background/65 lg:backdrop-blur-md lg:p-4 lg:shadow-2xl lg:shadow-black/50 lg:h-[calc(100%-3rem)]"
: "lg:absolute lg:right-4 lg:top-4 lg:bottom-4 lg:w-80 lg:rounded-xl lg:border lg:border-border/40 lg:bg-background/65 lg:backdrop-blur-md lg:p-4 lg:shadow-2xl lg:shadow-black/50 lg:h-[calc(100%-2rem)]",
"w-full p-4 border-t border-border/60 bg-card/45 shrink-0 flex flex-col"
)}>
<div className="flex items-center gap-2 mb-3 shrink-0">
<span className="w-2.5 h-2.5 rounded-full" style={{ background: CAT_COLOR_MAP[sel.category] }} />
<span className="text-[9px] font-bold uppercase tracking-wider" style={{ color: CAT_COLOR_MAP[sel.category] }}>
{CAT_CONFIG[sel.category]?.label ?? sel.category}
</span>
<span className={`ml-auto text-[9px] font-mono flex items-center gap-1 ${AUTO_SOURCES.has(sel.source) ? "text-emerald-400" : "text-muted-foreground/70"}`}>
{AUTO_SOURCES.has(sel.source) && <Sparkles className="h-2.5 w-2.5" />}
{sel.source}
</span>
<button onClick={() => setSelected(null)} className="text-muted-foreground hover:text-foreground ml-1.5 cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<div className="flex-1 overflow-y-auto pr-1 scrollbar-thin text-xs text-foreground leading-relaxed mb-4 break-words whitespace-pre-wrap text-left">
{sel.content}
</div>
<div className="mt-auto shrink-0 space-y-3 pt-3 border-t border-border/30">
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 flex items-center gap-1">
<Share2 className="h-2.5 w-2.5" /> verwandte Fakten
</div>
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto scrollbar-none text-left">
{neighbors.length ? neighbors.map((n) => (
<button key={n.id} onClick={() => setSelected(n.id)}
className="flex items-center gap-2 text-[10px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer py-0.5">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR_MAP[n.category] }} />
<span className="truncate">{n.content}</span>
</button>
)) : <span className="text-[10px] text-muted-foreground/60"></span>}
</div>
<button onClick={() => { del(sel.id); setSelected(null) }}
className="flex w-full items-center justify-center gap-1.5 text-[10px] font-bold text-red-400 border border-red-500/20 rounded-lg py-2 hover:bg-red-500/5 transition-all cursor-pointer">
<Trash2 className="h-3.5 w-3.5" /> Eintrag vergessen
</button>
</div>
</div>
)}
</div>
)}
{dialogElement}
</div>
)
}
+2 -18
View File
@@ -1,19 +1,18 @@
import { useState } from "react"
import {
Boxes, Brain, Server, ShieldAlert, Plug,
Boxes, Server, ShieldAlert, Plug,
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
Inbox, HelpCircle,
} from "lucide-react"
import { api } from "@/lib/api"
import {
useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth,
useModels, useServices, useUpdates, useAgentStatus, useConnectHealth,
useAuftragsbuch, 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 { ErinnerungenCard } from "./ErinnerungenCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { LatencyCard } from "@/components/dashboard/LatencyCard"
@@ -28,7 +27,6 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
const { data: models } = useModels()
const { data: svc } = useServices()
const { data: updates } = useUpdates()
const { data: memory } = useMemory()
const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth()
const { data: auftraege } = useAuftragsbuch()
@@ -66,7 +64,6 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
const svcOk = svc?.services.filter((s) => s.ok).length ?? 0
const svcTotal = svc?.services.length ?? 0
const svcErrors = svcTotal - svcOk
const notes = memory?.length ?? 0
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)
@@ -85,7 +82,6 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
// 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 autoNotes = memory ? memory.filter((m: any) => ["auto", "agent", "hermes"].includes(m.source)).length : 0
const failedSvc = svc?.services.filter((s: any) => !s.ok).map((s: any) => s.name).join(", ")
const upParts = updates ? [
@@ -143,12 +139,6 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
hint={brainName ? `Hirn: ${brainName}` : "Kein Hermes-Modell geladen"}
onClick={() => onNavigate("models")}
/>
<CockpitTile
icon={Brain} title="Gedächtnis" value={memory ? notes : "…"} unit="Notizen"
tone={memory ? "ok" : "loading"}
hint={memory ? `${notes} Fakten (${autoNotes} auto gelernt)` : "Gedächtnis-Pool laden..."}
onClick={() => onNavigate("memory")}
/>
<CockpitTile
icon={Server} title="Dienste" value={svc ? `${svcOk}/${svcTotal}` : "…"} unit="laufen"
tone={!svc ? "loading" : svcErrors > 0 ? "warn" : "ok"}
@@ -186,12 +176,6 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
onNavigate={onNavigate}
/>
{/* Anstehendes — Erinnerungen & Routinen */}
<section>
<ZoneLabel>Anstehendes</ZoneLabel>
<ErinnerungenCard />
</section>
{/* Werkzeuge & Shortcuts */}
<section>
<ZoneLabel>Werkzeuge & Diagnose</ZoneLabel>
@@ -1,117 +0,0 @@
import { useState } from "react"
import { AlarmClock, Plus, Trash2, Loader2, X, Repeat } from "lucide-react"
import { api } from "@/lib/api"
import { useReminders, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
// „Anstehendes": Erinnerungen & Routinen sichtbar und klickbar machen. Die API kann
// längst alles (Lucy legt per Stimme an) — hier kommt nur die GUI-Hälfte dazu.
const REPEAT_LABEL: Record<string, string> = {
"": "einmalig", daily: "täglich", weekdays: "werktags", weekly: "wöchentlich",
}
export function ErinnerungenCard() {
const { data: items = [], isLoading } = useReminders()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [showAdd, setShowAdd] = useState(false)
const [text, setText] = useState("")
const [when, setWhen] = useState("")
const [repeat, setRepeat] = useState("")
const [saving, setSaving] = useState(false)
const reload = () => qc.invalidateQueries({ queryKey: qk.reminders })
async function add() {
if (!text.trim() || !when) return
setSaving(true)
try {
await api("/api/reminders", { method: "POST", body: JSON.stringify({ text, when, repeat }) })
setText(""); setWhen(""); setRepeat(""); setShowAdd(false)
reload()
} catch (e: any) {
showAlert("Anlegen fehlgeschlagen", String(e?.message || e))
} finally {
setSaving(false)
}
}
function del(id: number, t: string) {
showConfirm("Erinnerung löschen?", `${t}" wird entfernt.`, async () => {
try { await api(`/api/reminders/${id}`, { method: "DELETE" }); reload() }
catch (e: any) { showAlert("Fehler", String(e?.message || e)) }
})
}
const sorted = [...items].sort((a, b) => a.next_ts - b.next_ts)
return (
<div className="mc-card p-4">
<div className="mb-3 flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-bold text-foreground">
<span className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-amber-300">
<AlarmClock className="h-4 w-4" />
</span>
Anstehendes
</span>
<button onClick={() => setShowAdd((s) => !s)}
className="flex h-8 items-center gap-1 rounded-lg border border-border/60 bg-background/30 px-2.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer">
{showAdd ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />} {showAdd ? "Abbrechen" : "Neu"}
</button>
</div>
{showAdd && (
<div className="mb-3 space-y-2 rounded-xl border border-border/50 bg-background/30 p-3">
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Woran soll Lucy erinnern?"
aria-label="Erinnerungstext"
className="h-9 w-full rounded-lg border border-border/60 bg-card/45 px-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50" />
<div className="flex flex-wrap items-center gap-2">
<input type="datetime-local" value={when} onChange={(e) => setWhen(e.target.value)}
aria-label="Zeitpunkt"
className="h-9 rounded-lg border border-border/60 bg-card/45 px-2 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50 [color-scheme:dark]" />
<select value={repeat} onChange={(e) => setRepeat(e.target.value)} aria-label="Wiederholung"
className="h-9 cursor-pointer rounded-lg border border-border/60 bg-card/45 px-2 text-xs font-semibold text-foreground outline-none">
{Object.entries(REPEAT_LABEL).map(([v, l]) => <option key={v} value={v} className="bg-popover">{l}</option>)}
</select>
<button onClick={add} disabled={saving || !text.trim() || !when}
className="ml-auto flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3 text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-50">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />} Anlegen
</button>
</div>
</div>
)}
{isLoading ? (
<p className="text-xs text-muted-foreground">Wird geladen </p>
) : sorted.length === 0 ? (
<p className="text-xs text-muted-foreground">Keine Erinnerungen. Lucy sagt dir hier (und per Stimme) Bescheid, wenn etwas ansteht.</p>
) : (
<div className="space-y-1.5">
{sorted.slice(0, 6).map((r) => {
const overdue = r.next_ts * 1000 < Date.now() && !r.repeat
return (
<div key={r.id} className="group flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-background/20 px-2.5 py-2">
<div className="min-w-0">
<p className="truncate text-xs text-foreground">{r.text}</p>
<p className={cn("flex items-center gap-1.5 text-[10px]", overdue ? "text-amber-300" : "text-muted-foreground/70")}>
{new Date(r.next_ts * 1000).toLocaleString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
{r.repeat && <span className="flex items-center gap-0.5"><Repeat className="h-2.5 w-2.5" />{REPEAT_LABEL[r.repeat] ?? r.repeat}</span>}
{overdue && "· überfällig"}
</p>
</div>
<button onClick={() => del(r.id, r.text)} aria-label="Erinnerung löschen"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground opacity-0 transition-all hover:bg-red-500/5 hover:text-red-400 group-hover:opacity-100 cursor-pointer">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
})}
{sorted.length > 6 && <p className="text-[10px] text-muted-foreground/60">+{sorted.length - 6} weitere</p>}
</div>
)}
{dialogElement}
</div>
)
}