Files
mission-control-v2/frontend/src/views/MemoryView.tsx
T
Hitonabi 26224804ed Tab-Review-Fixes: toter Gedaechtnis-Knopf, Konsole-Restart, Cockpit-Dopplung
- Gedaechtnis: 'Im Terminal starten' navigierte zu nicht existenter View 'terminal'
  -> 'konsole'; interne Duplikate (AUTO-Set, CAT_LABEL_MAP) entfernt
- Konsole: box-console jetzt in Dienste-Liste + Restart-Allowlist; Offline-Overlay
  bekommt Ein-Klick-'Dienst neu starten' statt SSH-Anweisung
- Cockpit Werkzeuge: Doppel-Eintrag ('Box-Shell' + 'Interaktives Terminal', beide
  -> Konsole) zu EINEM Eintrag 'Konsole (Box-Shell)' zusammengelegt
- Auftragsbuch: 'Nichts zu entscheiden'-Banner beruecksichtigt blockierte
  Ideen-Karten; Abschnitt heisst neutral 'Patch-Vorschlaege'
- Hermes-Diagnose: Klickweg (Dienste-Schublade oeffnen) statt veraltetem
  Drawer-Namen + systemctl-Kommandos
- Entdecken: Upgrade-Knopf uebernimmt fremde Quant nicht mehr blind
- Skills: Status-Badge zeigt Klartext (abgeschaltet/defekt/...) statt rohem State

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:42:04 +02:00

478 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}