Politur: Verschmelzung von Liste, Graph und Details zu einem einheitlichen, schwebenden Glassmorphism-Dashboard (Graph im Hintergrund, Panels schweben elegant davor)

This commit is contained in:
Hitonabi
2026-07-09 12:27:46 +02:00
parent 895eb9cac7
commit 2ed7100f90
9 changed files with 1165 additions and 1214 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-Ch3qlJmN.js"></script> <script type="module" crossorigin src="/assets/index-P6XXlxiQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1USExdfg.css"> <link rel="stylesheet" crossorigin href="/assets/index-CunuYLZu.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+130 -207
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import ForceGraph2D from "react-force-graph-2d" import ForceGraph2D from "react-force-graph-2d"
import { forceCollide, forceX, forceY } from "d3-force" import { forceCollide, forceX, forceY } from "d3-force"
import { Trash2, Sparkles, Share2, RefreshCw } from "lucide-react" import { RefreshCw } from "lucide-react"
import { type MemoryGraph } from "@/lib/api" import { type MemoryGraph } from "@/lib/api"
const CAT_COLOR: Record<string, string> = { const CAT_COLOR: Record<string, string> = {
@@ -10,7 +10,6 @@ const CAT_COLOR: Record<string, string> = {
const CAT_LABEL: Record<string, string> = { const CAT_LABEL: Record<string, string> = {
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse", identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
} }
const AUTO = new Set(["auto", "agent", "hermes"])
const EDGE_HI = "rgba(99, 102, 241, 0.9)" const EDGE_HI = "rgba(99, 102, 241, 0.9)"
const DIM_EDGE = "rgba(255, 255, 255, 0.02)" const DIM_EDGE = "rgba(255, 255, 255, 0.02)"
@@ -22,8 +21,11 @@ function hexToRgba(hex: string, alpha: number): string {
return `rgba(${r}, ${g}, ${b}, ${alpha})` return `rgba(${r}, ${g}, ${b}, ${alpha})`
} }
export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) { export function GraphView({ data, selectedNodeId, onNodeSelect }: {
const [selected, setSelected] = useState<string | null>(null) data: MemoryGraph
selectedNodeId: string | null
onNodeSelect: (id: string | null) => void
}) {
const [hovered, setHovered] = useState<string | null>(null) const [hovered, setHovered] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const fgRef = useRef<any>(null) const fgRef = useRef<any>(null)
@@ -114,27 +116,14 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
fg.d3ReheatSimulation() fg.d3ReheatSimulation()
}, [graphData]) }, [graphData])
const activeNode = hovered || selected const activeNode = hovered || selectedNodeId
// Layout zurücksetzen/neu anschubsen // Layout zurücksetzen/neu anschubsen
const relayout = () => { const relayout = () => {
if (!fgRef.current) return if (!fgRef.current) return
fgRef.current.zoomToFit(400, 40) fgRef.current.zoomToFit(400, 40)
} }
const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
// Nachbarn über das statische, sichere Props-Array ermitteln
const neighbors = useMemo(() => {
if (!selected) return []
const ids = new Set<string>()
data.edges.forEach((e) => {
if (e.source === selected) ids.add(e.target)
if (e.target === selected) ids.add(e.source)
})
return data.nodes.filter((n) => ids.has(n.id))
}, [selected, data])
// Nachbarmenge für extrem schnellen Lookup im Render-Loop // Nachbarmenge für extrem schnellen Lookup im Render-Loop
const connectedNodes = useMemo(() => { const connectedNodes = useMemo(() => {
if (!activeNode) return new Set<string>() if (!activeNode) return new Set<string>()
@@ -146,207 +135,141 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
return set return set
}, [activeNode, data]) }, [activeNode, data])
if (!data.nodes.length) {
return (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
Noch keine Fakten der Graph füllt sich, sobald Hermes lernt oder du Einträge anlegst.
</div>
)
}
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-3"> <div className="absolute inset-0" ref={containerRef}
<div className="relative h-[calc(100vh-13rem)] min-h-[560px] rounded-2xl border border-border/60 overflow-hidden" style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
ref={containerRef}
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)"
nodeRelSize={4}
cooldownTicks={120}
<ForceGraph2D // Hover- und Klick-Logik
ref={fgRef} onNodeClick={(node: any) => onNodeSelect(node.id)}
width={dimensions.width} onBackgroundClick={() => onNodeSelect(null)}
height={dimensions.height} onNodeHover={(node: any) => {
graphData={graphData} setHovered(node ? node.id : null)
backgroundColor="rgba(0,0,0,0)" if (containerRef.current) {
nodeRelSize={4} containerRef.current.style.cursor = node ? "pointer" : "default"
cooldownTicks={120} }
}}
// Kanten-Animationen & Partikelfluss
linkColor={(link: any) => {
const sId = typeof link.source === "object" ? link.source.id : link.source
const tId = typeof link.target === "object" ? link.target.id : link.target
// Hover- und Klick-Logik if (activeNode === sId || activeNode === tId) return EDGE_HI
onNodeClick={(node: any) => setSelected(node.id)} if (activeNode) return DIM_EDGE
onBackgroundClick={() => setSelected(null)}
onNodeHover={(node: any) => {
setHovered(node ? node.id : null)
if (containerRef.current) {
containerRef.current.style.cursor = node ? "pointer" : "default"
}
}}
// Kanten-Animationen & Partikelfluss const sourceNode = graphData.nodes.find(n => n.id === sId)
linkColor={(link: any) => { const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b"
const sId = typeof link.source === "object" ? link.source.id : link.source return hexToRgba(catColor, 0.25)
const tId = typeof link.target === "object" ? link.target.id : link.target }}
linkWidth={(link: any) => {
// Highlight-Kanten leuchten in Indigo auf const sId = typeof link.source === "object" ? link.source.id : link.source
if (activeNode === sId || activeNode === tId) return EDGE_HI const tId = typeof link.target === "object" ? link.target.id : link.target
return (activeNode === sId || activeNode === tId) ? 2.5 : 1.4
// Wenn ein anderer Knoten aktiv ist, blenden wir diese Kante fast komplett aus }}
if (activeNode) return DIM_EDGE
// Daten-Partikel fließen auf den aktiven Verbindungen
// Standard-Zustand: Kante leuchtet in der Farbe des Quell-Clusters (deutlich sichtbar mit alpha=0.25) linkDirectionalParticles={(link: any) => {
const sourceNode = graphData.nodes.find(n => n.id === sId) const sId = typeof link.source === "object" ? link.source.id : link.source
const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b" const tId = typeof link.target === "object" ? link.target.id : link.target
return hexToRgba(catColor, 0.25) return (activeNode === sId || activeNode === tId) ? 4 : 0
}} }}
linkWidth={(link: any) => { linkDirectionalParticleWidth={2.2}
const sId = typeof link.source === "object" ? link.source.id : link.source linkDirectionalParticleSpeed={0.006}
const tId = typeof link.target === "object" ? link.target.id : link.target
return (activeNode === sId || activeNode === tId) ? 2.5 : 1.4 // Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen
}} nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const x = node.x ?? 0
const y = node.y ?? 0
const size = 5.5 + Math.min(degree[node.id] || 0, 12) * 0.9
const color = CAT_COLOR[node.category] || "#64748b"
const isHovered = node.id === hovered
const isSelected = node.id === selectedNodeId
// Daten-Partikel fließen auf den aktiven Verbindungen const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
linkDirectionalParticles={(link: any) => { const finalColor = isDimmed ? "#1e293b" : color
const sId = typeof link.source === "object" ? link.source.id : link.source
const tId = typeof link.target === "object" ? link.target.id : link.target
return (activeNode === sId || activeNode === tId) ? 4 : 0
}}
linkDirectionalParticleWidth={2.2}
linkDirectionalParticleSpeed={0.006}
// Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const x = node.x ?? 0
const y = node.y ?? 0
const size = 5.5 + Math.min(degree[node.id] || 0, 12) * 0.9
const color = CAT_COLOR[node.category] || "#64748b"
const isHovered = node.id === hovered
const isSelected = node.id === selected
// Abgedunkelter Zustand, wenn ein anderer Knoten aktiv ist und dieser kein Nachbar ist
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
const finalColor = isDimmed ? "#1e293b" : color
// 1. Zeichne weichen Glow-Schatten if (!isDimmed) {
if (!isDimmed) { ctx.shadowColor = color
ctx.shadowColor = color ctx.shadowBlur = isHovered || isSelected ? 12 : 6
ctx.shadowBlur = isHovered || isSelected ? 12 : 6 }
}
ctx.beginPath()
// 2. Zeichne den Knoten-Kreis ctx.arc(x, y, size, 0, 2 * Math.PI, false)
ctx.fillStyle = finalColor
ctx.fill()
ctx.shadowBlur = 0
if (isHovered || isSelected) {
ctx.beginPath() ctx.beginPath()
ctx.arc(x, y, size, 0, 2 * Math.PI, false) ctx.arc(x, y, size + 2, 0, 2 * Math.PI, false)
ctx.fillStyle = finalColor ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)"
ctx.fill() ctx.lineWidth = 1.5
ctx.shadowBlur = 0 // Schatten zurücksetzen
// 3. Zeichne weißen Außenring bei Interaktivität
if (isHovered || isSelected) {
ctx.beginPath()
ctx.arc(x, y, size + 2, 0, 2 * Math.PI, false)
ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)"
ctx.lineWidth = 1.5
ctx.stroke()
}
// 4. Typografie (Immer scharf & mitskalierend gezeichnet)
const label = node.content.length > 30 ? node.content.slice(0, 29) + "…" : node.content
const baseFontSize = isHovered || isSelected ? 10 : 9
const fontSize = baseFontSize / Math.max(0.6, globalScale) // Verhindert Riesen-Schriften beim Rauszoomen
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
const textWidth = ctx.measureText(label).width
const bpad = 4 / Math.max(0.6, globalScale)
// Render Label-Hintergrund für absolute Lesbarkeit
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)"
ctx.beginPath()
// Zeichne abgerundete Pille unter dem Knoten
const rx = x - textWidth / 2 - bpad
const ry = y + size + (5 / Math.max(0.6, globalScale))
const rw = textWidth + bpad * 2
const rh = fontSize + bpad * 1.5
const rad = 4 / Math.max(0.6, globalScale)
ctx.beginPath()
ctx.moveTo(rx + rad, ry)
ctx.lineTo(rx + rw - rad, ry)
ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + rad)
ctx.lineTo(rx + rw, ry + rh - rad)
ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - rad, ry + rh)
ctx.lineTo(rx + rad, ry + rh)
ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - rad)
ctx.lineTo(rx, ry + rad)
ctx.quadraticCurveTo(rx, ry, rx + rad, ry)
ctx.closePath()
ctx.fill()
// Feine Kontur für die Text-Pille
ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.05)"
ctx.lineWidth = 0.5 / Math.max(0.6, globalScale)
ctx.stroke() ctx.stroke()
}
// Text zeichnen const label = node.content.length > 30 ? node.content.slice(0, 29) + "…" : node.content
ctx.textAlign = "center" const baseFontSize = isHovered || isSelected ? 10 : 9
ctx.textBaseline = "top" const fontSize = baseFontSize / Math.max(0.6, globalScale)
ctx.fillStyle = isDimmed
? "#475569" ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
: (isHovered || isSelected ? "#ffffff" : "#cbd5e1") const textWidth = ctx.measureText(label).width
const bpad = 4 / Math.max(0.6, globalScale)
ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale)))
}} ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)"
/> ctx.beginPath()
const rx = x - textWidth / 2 - bpad
const ry = y + size + (5 / Math.max(0.6, globalScale))
const rw = textWidth + bpad * 2
const rh = fontSize + bpad * 1.5
const rad = 4 / Math.max(0.6, globalScale)
ctx.beginPath()
ctx.moveTo(rx + rad, ry)
ctx.lineTo(rx + rw - rad, ry)
ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + rad)
ctx.lineTo(rx + rw, ry + rh - rad)
ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - rad, ry + rh)
ctx.lineTo(rx + rad, ry + rh)
ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - rad)
ctx.lineTo(rx, ry + rad)
ctx.quadraticCurveTo(rx, ry, rx + rad, ry)
ctx.closePath()
ctx.fill()
<button onClick={relayout} title="Knoten neu anordnen" ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.05)"
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"> ctx.lineWidth = 0.5 / Math.max(0.6, globalScale)
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren ctx.stroke()
</button>
{/* Legende */} ctx.textAlign = "center"
<div className="absolute left-3 bottom-3 flex flex-wrap gap-2 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm"> ctx.textBaseline = "top"
{Object.entries(CAT_LABEL).map(([k, l]) => ( ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
<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} ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale)))
</span> }}
))} />
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 h-[calc(100vh-13rem)] min-h-[560px] overflow-y-auto scrollbar-thin"> <button onClick={relayout} title="Zentrieren"
{sel ? ( 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
<div className="flex items-center gap-2 mb-3"> </button>
<span className="w-3 h-3 rounded-full" style={{ background: CAT_COLOR[sel.category], boxShadow: `0 0 0 2px ${AUTO.has(sel.source) ? "#34d399" : "#475569"}` }} />
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: CAT_COLOR[sel.category] }}> {/* Legende */}
{CAT_LABEL[sel.category] || sel.category} <div className="absolute left-3 bottom-3 flex flex-wrap gap-2 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm">
</span> {Object.entries(CAT_LABEL).map(([k, l]) => (
<span className={`ml-auto text-[10px] font-mono flex items-center gap-1 ${AUTO.has(sel.source) ? "text-emerald-400" : "text-muted-foreground/70"}`}> <span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
{AUTO.has(sel.source) && <Sparkles className="h-2.5 w-2.5" />}{sel.source} <span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
</span> </span>
</div> ))}
<div className="text-sm text-foreground leading-relaxed mb-4 break-words">{sel.content}</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 mb-2 flex items-center gap-1">
<Share2 className="h-3 w-3" /> verwandte Fakten
</div>
<div className="flex flex-col gap-1.5 mb-4">
{neighbors.length ? neighbors.map((n) => (
<button key={n.id} onClick={() => setSelected(n.id)}
className="flex items-center gap-2 text-[11px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR[n.category] }} />
<span className="truncate">{n.content}</span>
</button>
)) : <span className="text-[11px] text-muted-foreground/60"></span>}
</div>
<button onClick={() => { onDelete(sel.id); setSelected(null) }}
className="flex items-center gap-1.5 text-[11px] text-red-400 border border-red-500/20 rounded-lg px-2.5 py-1.5 hover:bg-red-500/5 transition-all cursor-pointer">
<Trash2 className="h-3.5 w-3.5" /> vergessen
</button>
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center text-muted-foreground/70 gap-2 pt-24">
<Share2 className="h-6 w-6" />
<div className="text-xs max-w-[180px] leading-relaxed">
Auf einen Knoten klicken, um den Fakt und seine semantischen Nachbarn zu sehen. Hover hebt das Netz hervor.
</div>
</div>
)}
</div> </div>
</div> </div>
) )
+223 -195
View File
@@ -31,15 +31,13 @@ const BORDER_CLASSES: Record<string, string> = {
} }
// Onboarding-Prompt: der Nutzer startet damit ein Gespräch mit Hermes (Terminal/Telegram). // Onboarding-Prompt: der Nutzer startet damit ein Gespräch mit Hermes (Terminal/Telegram).
// Hermes interviewt ihn Schritt für Schritt — der Auto-Lern-Hook füllt dabei das Gedächtnis.
// Bewusst GENERISCH (nicht auf eine Person zugeschnitten).
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: 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, 1) wer ich bin und woran ich gerade arbeite,
2) wie ich angesprochen werden möchte, 2) wie ich angesprochen werden möchte,
3) meine bevorzugten Tools, Sprachen und Arbeitsweise, 3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
4) wichtige Regeln/Konventionen, die du beachten sollst, 4) wichtige Regeln/Konventionen, die du beachten sollst,
5) meine Infrastruktur (Server, Dienste ohne Geheimnisse). 5) meine Infrastruktur (Server, Dienste ohne Geheimnisse).
Frag immer nur eine Sache auf einmal und fass am Ende zusammen, was du dir gemerkt hast.` Frag immer nur eine sache auf einmal und fass am Ende zusammen, was du dir gemerkt hast.`
export function MemoryView() { export function MemoryView() {
const [filter, setFilter] = useState("") const [filter, setFilter] = useState("")
@@ -50,6 +48,7 @@ export function MemoryView() {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [view, setView] = useState<"liste" | "graph">("liste") const [view, setView] = useState<"liste" | "graph">("liste")
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [selected, setSelected] = useState<string | null>(null)
const qc = useQueryClient() const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog() const { showAlert, showConfirm, dialogElement } = useDialog()
@@ -134,203 +133,222 @@ export function MemoryView() {
}) })
} catch (e: any) { } catch (e: any) {
showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`) showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`)
} finally { setDeduping(false) } } finally {
setDeduping(false)
}
} }
const StatChip = ({ value, label, accent }: { value: number; label: string; accent?: "auto" }) => ( const sel = useMemo(() => items.find((m) => m.id === selected), [items, selected])
<span className={cn("flex items-center gap-1.5 text-[11px] rounded-lg px-2.5 py-1 border", const neighbors = useMemo(() => {
accent === "auto" ? "text-emerald-400 bg-emerald-500/[0.07] border-emerald-500/20" : "text-muted-foreground bg-card/40 border-border/50")}> if (!sel || !graph) return []
{accent === "auto" && <Sparkles className="h-3 w-3" />} const connectedIds = new Set([
<b className={cn("font-semibold", accent === "auto" ? "" : "text-foreground")}>{value}</b> {label} ...graph.edges.filter((e) => e.source === sel.id).map((e) => e.target),
</span> ...graph.edges.filter((e) => e.target === sel.id).map((e) => e.source)
) ])
return items.filter((m) => connectedIds.has(m.id))
}, [sel, graph, items])
const CAT_COLOR_MAP: Record<string, string> = {
identity: "#00f5ff", knowledge: "#3b82f6", rules: "#d946ef", events: "#fbbf24",
}
const CAT_LABEL_MAP: Record<string, string> = {
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
}
const AUTO = new Set(["auto", "agent", "hermes"])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Zweispaltiges Haupt-Layout ab Y=0 */} {empty ? (
<div className="flex flex-col lg:flex-row gap-6 items-start"> <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">
{/* Linke Spalte (Titel, Statistiken, Filter, Liste) */} <MessagesSquare className="h-7 w-7 text-primary" />
<div className="w-full lg:max-w-md xl:max-w-xl lg:shrink-0 space-y-5"> </div>
{/* Titel & Beschreibung */} <div className="space-y-1.5 max-w-lg">
<div> <h3 className="text-base font-semibold text-foreground">Lass Hermes dich kennenlernen</h3>
<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"> <p className="text-xs text-muted-foreground leading-relaxed">
Gedächtnis-Pool Statt Fakten einzutippen: führ ein kurzes Onboarding-Gespräch mit Hermes. Was ihr besprecht,
</h1> merkt sich das Gedächtnis automatisch du musst nichts manuell pflegen.
<p className="text-sm text-muted-foreground">
Geteilte Konstitution Hermes, IDEs &amp; Gateway lesen und lernen hier per MCP.
</p> </p>
</div> </div>
{/* Statistiken (Chips) */} {/* Vorgeschlagener Prompt */}
{!empty && ( <div className="w-full max-w-lg text-left">
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center justify-between mb-1.5">
<StatChip value={stats.total} label="Fakten" /> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">Sende das an Hermes</span>
<StatChip value={stats.auto} label="auto gelernt" accent="auto" /> <button onClick={copyPrompt} className="flex items-center gap-1 text-[10px] font-semibold text-muted-foreground hover:text-foreground transition-colors">
<StatChip value={stats.manual} label="manuell" /> {copied ? <><Check className="h-3 w-3 text-emerald-400" /> Kopiert</> : <><Copy className="h-3 w-3" /> Kopieren</>}
<StatChip value={stats.cats} label="Kategorien" />
</div>
)}
{/* Mobile-Steuerung (nur sichtbar unter lg) */}
{!empty && (
<div className="flex flex-wrap items-center gap-2 lg:hidden">
<div className="relative flex-1 min-w-[140px]">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
aria-label="Gedächtnis durchsuchen"
type="search"
placeholder="Suchen…"
className="w-full h-9 pl-8 pr-3 rounded-lg border border-border/60 bg-card/45 text-xs 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)}
className="h-9 px-3 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer shadow-md shadow-primary/10">
<Plus className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 p-1 bg-card/30 border border-border/40 rounded-lg">
{([["liste", List], ["graph", Share2]] as const).map(([v, Icon]) => (
<button key={v} onClick={() => setView(v)}
className={cn("flex h-7 px-2.5 items-center gap-1.5 text-[11px] font-semibold rounded-md transition-all cursor-pointer",
view === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
<Icon className="h-3.5 w-3.5" />
</button>
))}
</div>
<button onClick={cleanup} disabled={deduping}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-card hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50">
<Sparkles className="h-4 w-4 text-primary" />
</button> </button>
</div> </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>
{/* Eintrag anlegen (einklappbar) */} <div className="flex flex-wrap items-center justify-center gap-2">
{showAdd && ( <button onClick={goTerminal}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 space-y-3"> 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">
<div className="flex items-center justify-between"> <TerminalIcon className="h-4 w-4" /> Im Terminal starten
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Neuen Eintrag anlegen</div> </button>
<button onClick={() => setShowAdd(false)} aria-label="Schließen" className="text-muted-foreground hover:text-foreground"><X className="h-4 w-4" aria-hidden="true" /></button> <button onClick={copyPrompt}
</div> 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">
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2} {copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />} Prompt kopieren
aria-label="Eintragstext" </button>
placeholder="Eine Regel, Vorliebe oder einen stabilen Fakt über dich oder das Projekt…" </div>
className="w-full resize-none rounded-xl border border-border/60 bg-background/30 p-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground leading-relaxed" /> <p className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5">
<div className="flex flex-wrap items-center justify-between gap-3"> <Send className="h-3 w-3" /> Funktioniert genauso über Telegram {" "}
<select value={category} onChange={(e) => setCategory(e.target.value)} <button onClick={() => setShowAdd(true)} className="underline hover:text-foreground">oder lieber manuell anlegen</button>.
aria-label="Kategorie" </p>
className="h-8 rounded-lg border border-border/60 bg-background/50 px-2 text-xs outline-none font-semibold text-foreground cursor-pointer"> </div>
{CATEGORIES.map((c) => <option key={c} value={c} className="bg-popover text-foreground">{CAT_CONFIG[c]?.label || c}</option>)} ) : (
</select> /* Der voll integrierte, schwebende Wissens-Hub */
<button onClick={add} 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"> <div className="relative w-full h-[calc(100vh-8.5rem)] min-h-[620px] rounded-2xl border border-border/60 bg-[#030712] overflow-hidden flex flex-col lg:block">
<Plus className="h-4 w-4" /> Speichern
</button> {/* 1. GraphView nimmt auf Desktop 100% des Hintergrunds ein */}
</div> <div className="absolute inset-0 z-0 hidden lg:block">
</div> <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>
{error && <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">Fehler: {error}</div>} {/* 2. Schwebendes linkes Panel (Wissens-Liste) */}
<div className={cn(
{/* Empty-State oder Listen-Inhalte */} "z-10 flex flex-col transition-all duration-300",
{empty ? ( "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)]",
<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"> "w-full h-full p-4 flex-1 lg:flex-none",
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center"> view === "graph" && "hidden lg:flex"
<MessagesSquare className="h-7 w-7 text-primary" /> )}>
</div> {/* Header & Steuerung direkt im Panel */}
<div className="space-y-1.5 max-w-lg"> <div className="space-y-3 mb-3 shrink-0">
<h3 className="text-base font-semibold text-foreground">Lass Hermes dich kennenlernen</h3> <div>
<p className="text-xs text-muted-foreground leading-relaxed"> <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">
Statt Fakten einzutippen: führ ein kurzes Onboarding-Gespräch mit Hermes. Was ihr besprecht, Gedächtnis-Pool
merkt sich das Gedächtnis automatisch du musst nichts manuell pflegen. </h1>
<p className="text-[11px] text-muted-foreground leading-relaxed mt-0.5">
IDEs &amp; Gateway lesen und lernen hier per MCP.
</p> </p>
</div> </div>
{/* Vorgeschlagener Prompt */} {/* Statistiken (Chips) */}
<div className="w-full max-w-lg text-left"> <div className="flex flex-wrap items-center gap-1.5">
<div className="flex items-center justify-between mb-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-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">Sende das an Hermes</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>
<button onClick={copyPrompt} className="flex items-center gap-1 text-[10px] font-semibold text-muted-foreground hover:text-foreground transition-colors"> <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>
{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>
<div className="flex flex-wrap items-center justify-center gap-2"> {/* Steuerungsleiste: Suchen, Eintrag, Deduplizieren */}
<button onClick={goTerminal} <div className="flex items-center gap-1.5 w-full">
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"> <div className="relative flex-1">
<TerminalIcon className="h-4 w-4" /> Im Terminal starten <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>
<button onClick={copyPrompt} <button onClick={cleanup} disabled={deduping} title="Deduplizieren"
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"> 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">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />} Prompt kopieren <Sparkles className="h-3.5 w-3.5 text-primary" />
</button> </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> </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>
) : (
<div className={cn("space-y-5", view === "graph" && "hidden lg:block")}>
{/* Kategorie-Filter */} {/* Kategorie-Filter */}
<div className="flex flex-wrap items-center gap-1.5 p-1 bg-card/30 border border-border/40 rounded-xl w-fit"> <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("")} <button onClick={() => setFilter("")}
className={cn("h-7 px-3 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer", 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> !filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>Alle</button>
{CATEGORIES.map((c) => { {CATEGORIES.map((c) => {
const conf = CAT_CONFIG[c] || DEFAULT_CAT const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon
return ( return (
<button key={c} onClick={() => setFilter(c)} <button key={c} onClick={() => setFilter(c)}
className={cn("h-7 px-2.5 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1", 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")}> filter === c ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
<Icon className="h-3 w-3" /> {conf.label} {conf.label}
</button> </button>
) )
})} })}
</div> </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 ? ( {items.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center bg-card/10"> <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 für die aktuellen Filterkriterien gefunden. Keine Einträge gefunden.
</div> </div>
) : ( ) : (
<div className="space-y-5 max-h-[calc(100vh-14rem)] overflow-y-auto pr-1 scrollbar-thin"> <div className="space-y-4">
{[...CATEGORIES, "__other"].map((c) => { {[...CATEGORIES, "__other"].map((c) => {
const list = c === "__other" ? otherItems : (grouped[c] || []) const list = c === "__other" ? otherItems : (grouped[c] || [])
if (!list.length) return null if (!list.length) return null
const conf = CAT_CONFIG[c] || DEFAULT_CAT const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon const Icon = conf.icon
return ( return (
<div key={c} className="space-y-2"> <div key={c} className="space-y-1.5">
{/* Sektions-Kopf je Kategorie */} <div className="flex items-center gap-1.5 px-0.5">
<div className="flex items-center gap-2 mb-2 px-1"> <Icon className={cn("h-3 w-3", conf.text)} />
<Icon className={cn("h-3.5 w-3.5", conf.text)} /> <span className={cn("text-[9px] font-bold uppercase tracking-wider", conf.text)}>{conf.label}</span>
<span className={cn("text-[11px] 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>
<span className="text-[10px] font-mono text-muted-foreground/60 bg-card/50 rounded px-1.5 py-0.5">{list.length}</span>
<div className="ml-1 h-px flex-1 bg-border/30" />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
{list.map((m) => { {list.map((m) => {
const isAuto = AUTO_SOURCES.has(m.source) const isAuto = AUTO_SOURCES.has(m.source)
const isSelected = selected === m.id
return ( return (
<div key={m.id} className={cn("flex items-start justify-between gap-4 p-3.5 rounded-xl border border-l-4 bg-card/45 backdrop-blur-md border-border/60 hover:border-primary/20 transition-all group", <div key={m.id} onClick={() => setSelected(isSelected ? null : m.id)}
BORDER_CLASSES[m.category] || "border-l-muted")}> className={cn(
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span> "flex items-start justify-between gap-3 p-2.5 rounded-lg border border-l-4 transition-all group cursor-pointer text-left",
<div className="flex items-center gap-2 shrink-0"> isSelected ? "bg-primary/10 border-primary shadow-md" : "bg-card/45 border-border/40 hover:bg-card/75",
{typeof m.score === "number" && ( BORDER_CLASSES[m.category] || "border-l-muted"
<span className="text-[9px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded" title="Relevanz der semantischen Suche">{Math.round(m.score * 100)}%</span> )}>
)} <span className="text-[11px] text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span>
<span className={cn("flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded", <div className="flex items-center gap-1.5 shrink-0 self-center">
isAuto ? "text-emerald-400 bg-emerald-500/10" : "text-muted-foreground/60 bg-background/20")} {isAuto && <span title="Auto gelernt"><Sparkles className="h-2.5 w-2.5 text-emerald-400" /></span>}
title={isAuto ? "Automatisch gelernt" : "Manuell angelegt"}> <button onClick={(e) => { e.stopPropagation(); del(m.id); if (selected === m.id) setSelected(null) }}
{isAuto && <Sparkles className="h-2.5 w-2.5" />}{m.source} 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">
</span> <Trash2 className="h-3.5 w-3.5" />
<button onClick={() => del(m.id)} aria-label="Eintrag löschen" title="Eintrag löschen"
className="h-7 w-7 rounded-lg 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" aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>
@@ -343,54 +361,64 @@ export function MemoryView() {
</div> </div>
)} )}
</div> </div>
)} </div>
</div>
{/* Rechte Spalte (Steuerungszeile & Graph-Bereich) */} {/* 3. Mobiler Fallback für den Graph (unter lg) */}
{!empty && ( <div className={cn("w-full h-[500px] relative lg:hidden", view === "liste" && "hidden")}>
<div className="flex-1 w-full space-y-5"> <GraphErrorBoundary>
{/* Desktop-Steuerungszeile (nur sichtbar ab lg) */} <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>}>
<div className="hidden lg:flex items-center justify-between gap-3 h-12"> <GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
<span className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5"> </Suspense>
<Sparkles className="h-3 w-3 text-emerald-400" /> lernt automatisch aus Hermes-Gesprächen </GraphErrorBoundary>
</span> </div>
{/* 4. Schwebendes rechtes Panel (Details zum ausgewählten Knoten) */}
{sel && (
<div className={cn(
"z-10 flex flex-col transition-all duration-300",
"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_LABEL_MAP[sel.category] || sel.category}
</span>
<span className={`ml-auto text-[9px] font-mono flex items-center gap-1 ${AUTO.has(sel.source) ? "text-emerald-400" : "text-muted-foreground/70"}`}>
{AUTO.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 items-center gap-2"> <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">
<div className="relative"> {sel.content}
<input </div>
value={q}
onChange={(e) => setQ(e.target.value)} <div className="mt-auto shrink-0 space-y-3 pt-3 border-t border-border/30">
aria-label="Gedächtnis durchsuchen" <div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 flex items-center gap-1">
type="search" <Share2 className="h-2.5 w-2.5" /> verwandte Fakten
placeholder="Semantisch suchen…"
className="w-56 h-9 pl-8 pr-3 rounded-lg border border-border/60 bg-card/45 text-xs 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> </div>
<button onClick={() => setShowAdd((s) => !s)} <div className="flex flex-col gap-1 max-h-40 overflow-y-auto scrollbar-none text-left">
className="h-9 px-3 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer shadow-md shadow-primary/10"> {neighbors.length ? neighbors.map((n) => (
<Plus className="h-4 w-4" /> Eintrag <button key={n.id} onClick={() => setSelected(n.id)}
</button> className="flex items-center gap-2 text-[10px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer py-0.5">
<button onClick={cleanup} disabled={deduping} <span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR_MAP[n.category] }} />
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-card hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50" <span className="truncate">{n.content}</span>
title="Deduplizieren"> </button>
<Sparkles className="h-4 w-4 text-primary" /> )) : <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> </button>
</div> </div>
</div> </div>
)}
{/* Graph-Sicht */} </div>
<div className={cn("w-full", view === "liste" && "hidden lg:block")}> )}
<GraphErrorBoundary>
<Suspense fallback={<div className="h-[480px] 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} onDelete={del} />
</Suspense>
</GraphErrorBoundary>
</div>
</div>
)}
</div>
{dialogElement} {dialogElement}
</div> </div>
) )