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>
+15 -92
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,7 +116,7 @@ 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 = () => {
@@ -122,19 +124,6 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
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,18 +135,8 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
return set return set
}, [activeNode, data]) }, [activeNode, data])
if (!data.nodes.length) {
return ( return (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center"> <div className="absolute inset-0" ref={containerRef}
Noch keine Fakten der Graph füllt sich, sobald Hermes lernt oder du Einträge anlegst.
</div>
)
}
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-3">
<div className="relative h-[calc(100vh-13rem)] min-h-[560px] rounded-2xl border border-border/60 overflow-hidden"
ref={containerRef}
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}> style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
<ForceGraph2D <ForceGraph2D
@@ -170,8 +149,8 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
cooldownTicks={120} cooldownTicks={120}
// Hover- und Klick-Logik // Hover- und Klick-Logik
onNodeClick={(node: any) => setSelected(node.id)} onNodeClick={(node: any) => onNodeSelect(node.id)}
onBackgroundClick={() => setSelected(null)} onBackgroundClick={() => onNodeSelect(null)}
onNodeHover={(node: any) => { onNodeHover={(node: any) => {
setHovered(node ? node.id : null) setHovered(node ? node.id : null)
if (containerRef.current) { if (containerRef.current) {
@@ -184,13 +163,9 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
const sId = typeof link.source === "object" ? link.source.id : link.source const sId = typeof link.source === "object" ? link.source.id : link.source
const tId = typeof link.target === "object" ? link.target.id : link.target const tId = typeof link.target === "object" ? link.target.id : link.target
// Highlight-Kanten leuchten in Indigo auf
if (activeNode === sId || activeNode === tId) return EDGE_HI if (activeNode === sId || activeNode === tId) return EDGE_HI
// Wenn ein anderer Knoten aktiv ist, blenden wir diese Kante fast komplett aus
if (activeNode) return DIM_EDGE if (activeNode) return DIM_EDGE
// Standard-Zustand: Kante leuchtet in der Farbe des Quell-Clusters (deutlich sichtbar mit alpha=0.25)
const sourceNode = graphData.nodes.find(n => n.id === sId) const sourceNode = graphData.nodes.find(n => n.id === sId)
const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b" const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b"
return hexToRgba(catColor, 0.25) return hexToRgba(catColor, 0.25)
@@ -217,26 +192,22 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
const size = 5.5 + Math.min(degree[node.id] || 0, 12) * 0.9 const size = 5.5 + Math.min(degree[node.id] || 0, 12) * 0.9
const color = CAT_COLOR[node.category] || "#64748b" const color = CAT_COLOR[node.category] || "#64748b"
const isHovered = node.id === hovered const isHovered = node.id === hovered
const isSelected = node.id === selected const isSelected = node.id === selectedNodeId
// Abgedunkelter Zustand, wenn ein anderer Knoten aktiv ist und dieser kein Nachbar ist
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id) const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
const finalColor = isDimmed ? "#1e293b" : color 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
} }
// 2. Zeichne den Knoten-Kreis
ctx.beginPath() ctx.beginPath()
ctx.arc(x, y, size, 0, 2 * Math.PI, false) ctx.arc(x, y, size, 0, 2 * Math.PI, false)
ctx.fillStyle = finalColor ctx.fillStyle = finalColor
ctx.fill() ctx.fill()
ctx.shadowBlur = 0 // Schatten zurücksetzen ctx.shadowBlur = 0
// 3. Zeichne weißen Außenring bei Interaktivität
if (isHovered || isSelected) { if (isHovered || isSelected) {
ctx.beginPath() ctx.beginPath()
ctx.arc(x, y, size + 2, 0, 2 * Math.PI, false) ctx.arc(x, y, size + 2, 0, 2 * Math.PI, false)
@@ -245,20 +216,17 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
ctx.stroke() ctx.stroke()
} }
// 4. Typografie (Immer scharf & mitskalierend gezeichnet)
const label = node.content.length > 30 ? node.content.slice(0, 29) + "…" : node.content const label = node.content.length > 30 ? node.content.slice(0, 29) + "…" : node.content
const baseFontSize = isHovered || isSelected ? 10 : 9 const baseFontSize = isHovered || isSelected ? 10 : 9
const fontSize = baseFontSize / Math.max(0.6, globalScale) // Verhindert Riesen-Schriften beim Rauszoomen const fontSize = baseFontSize / Math.max(0.6, globalScale)
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif` ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
const textWidth = ctx.measureText(label).width const textWidth = ctx.measureText(label).width
const bpad = 4 / Math.max(0.6, globalScale) 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.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)"
ctx.beginPath() ctx.beginPath()
// Zeichne abgerundete Pille unter dem Knoten
const rx = x - textWidth / 2 - bpad const rx = x - textWidth / 2 - bpad
const ry = y + size + (5 / Math.max(0.6, globalScale)) const ry = y + size + (5 / Math.max(0.6, globalScale))
const rw = textWidth + bpad * 2 const rw = textWidth + bpad * 2
@@ -278,23 +246,19 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
ctx.closePath() ctx.closePath()
ctx.fill() 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.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.lineWidth = 0.5 / Math.max(0.6, globalScale)
ctx.stroke() ctx.stroke()
// Text zeichnen
ctx.textAlign = "center" ctx.textAlign = "center"
ctx.textBaseline = "top" ctx.textBaseline = "top"
ctx.fillStyle = isDimmed ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
? "#475569"
: (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale))) ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale)))
}} }}
/> />
<button onClick={relayout} title="Knoten neu anordnen" <button onClick={relayout} title="Zentrieren"
className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors cursor-pointer"> 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 <RefreshCw className="h-3.5 w-3.5" /> Zentrieren
</button> </button>
@@ -308,46 +272,5 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
))} ))}
</div> </div>
</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">
{sel ? (
<>
<div className="flex items-center gap-2 mb-3">
<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] }}>
{CAT_LABEL[sel.category] || sel.category}
</span>
<span className={`ml-auto text-[10px] 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>
</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>
) )
} }
+219 -191
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,105 +133,33 @@ 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 */}
<div className="flex flex-col lg:flex-row gap-6 items-start">
{/* Linke Spalte (Titel, Statistiken, Filter, Liste) */}
<div className="w-full lg:max-w-md xl:max-w-xl lg:shrink-0 space-y-5">
{/* Titel & Beschreibung */}
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Gedächtnis-Pool
</h1>
<p className="text-sm text-muted-foreground">
Geteilte Konstitution Hermes, IDEs &amp; Gateway lesen und lernen hier per MCP.
</p>
</div>
{/* Statistiken (Chips) */}
{!empty && (
<div className="flex flex-wrap items-center gap-2">
<StatChip value={stats.total} label="Fakten" />
<StatChip value={stats.auto} label="auto gelernt" accent="auto" />
<StatChip value={stats.manual} label="manuell" />
<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>
</div>
)}
{/* Eintrag anlegen (einklappbar) */}
{showAdd && (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Neuen Eintrag anlegen</div>
<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>
</div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2}
aria-label="Eintragstext"
placeholder="Eine Regel, Vorliebe oder einen stabilen Fakt über dich oder das Projekt…"
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" />
<div className="flex flex-wrap items-center justify-between gap-3">
<select value={category} onChange={(e) => setCategory(e.target.value)}
aria-label="Kategorie"
className="h-8 rounded-lg border border-border/60 bg-background/50 px-2 text-xs outline-none font-semibold 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-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">
<Plus className="h-4 w-4" /> Speichern
</button>
</div>
</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>}
{/* Empty-State oder Listen-Inhalte */}
{empty ? ( {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="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"> <div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center">
@@ -273,124 +200,225 @@ export function MemoryView() {
</p> </p>
</div> </div>
) : ( ) : (
<div className={cn("space-y-5", view === "graph" && "hidden lg:block")}> /* Der voll integrierte, schwebende Wissens-Hub */
{/* Kategorie-Filter */} <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">
<div className="flex flex-wrap items-center gap-1.5 p-1 bg-card/30 border border-border/40 rounded-xl w-fit">
<button onClick={() => setFilter("")} {/* 1. GraphView nimmt auf Desktop 100% des Hintergrunds ein */}
className={cn("h-7 px-3 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer", <div className="absolute inset-0 z-0 hidden lg:block">
!filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>Alle</button> <GraphErrorBoundary>
{CATEGORIES.map((c) => { <Suspense fallback={<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
const conf = CAT_CONFIG[c] || DEFAULT_CAT <GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
const Icon = conf.icon </Suspense>
return ( </GraphErrorBoundary>
<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",
filter === c ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
<Icon className="h-3 w-3" /> {conf.label}
</button>
)
})}
</div> </div>
{items.length === 0 ? ( {/* 2. Schwebendes linkes Panel (Wissens-Liste) */}
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center bg-card/10"> <div className={cn(
Keine Einträge für die aktuellen Filterkriterien gefunden. "z-10 flex flex-col transition-all duration-300",
</div> "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",
<div className="space-y-5 max-h-[calc(100vh-14rem)] overflow-y-auto pr-1 scrollbar-thin"> view === "graph" && "hidden lg:flex"
{[...CATEGORIES, "__other"].map((c) => { )}>
const list = c === "__other" ? otherItems : (grouped[c] || []) {/* Header & Steuerung direkt im Panel */}
if (!list.length) return null <div className="space-y-3 mb-3 shrink-0">
const conf = CAT_CONFIG[c] || DEFAULT_CAT <div>
const Icon = conf.icon <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">
return ( Gedächtnis-Pool
<div key={c} className="space-y-2"> </h1>
{/* Sektions-Kopf je Kategorie */} <p className="text-[11px] text-muted-foreground leading-relaxed mt-0.5">
<div className="flex items-center gap-2 mb-2 px-1"> IDEs &amp; Gateway lesen und lernen hier per MCP.
<Icon className={cn("h-3.5 w-3.5", conf.text)} /> </p>
<span className={cn("text-[11px] font-bold uppercase tracking-wider", conf.text)}>{conf.label}</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 className="space-y-2">
{list.map((m) => {
const isAuto = AUTO_SOURCES.has(m.source)
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",
BORDER_CLASSES[m.category] || "border-l-muted")}>
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span>
<div className="flex items-center gap-2 shrink-0">
{typeof m.score === "number" && (
<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={cn("flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded",
isAuto ? "text-emerald-400 bg-emerald-500/10" : "text-muted-foreground/60 bg-background/20")}
title={isAuto ? "Automatisch gelernt" : "Manuell angelegt"}>
{isAuto && <Sparkles className="h-2.5 w-2.5" />}{m.source}
</span>
<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>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div> </div>
{/* Rechte Spalte (Steuerungszeile & Graph-Bereich) */} {/* Statistiken (Chips) */}
{!empty && ( <div className="flex flex-wrap items-center gap-1.5">
<div className="flex-1 w-full space-y-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>
{/* Desktop-Steuerungszeile (nur sichtbar ab lg) */} <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>
<div className="hidden lg:flex items-center justify-between gap-3 h-12"> <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>
<span className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5"> </div>
<Sparkles className="h-3 w-3 text-emerald-400" /> lernt automatisch aus Hermes-Gesprächen
</span>
<div className="flex items-center gap-2"> {/* Steuerungsleiste: Suchen, Eintrag, Deduplizieren */}
<div className="relative"> <div className="flex items-center gap-1.5 w-full">
<div className="relative flex-1">
<input <input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
aria-label="Gedächtnis durchsuchen" aria-label="Gedächtnis durchsuchen"
type="search" type="search"
placeholder="Semantisch suchen…" 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" 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" /> <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)} <button onClick={() => setShowAdd((s) => !s)} title="Eintrag hinzufügen"
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"> 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" /> Eintrag <Plus className="h-4 w-4" />
</button> </button>
<button onClick={cleanup} disabled={deduping} <button onClick={cleanup} disabled={deduping} title="Deduplizieren"
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" 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">
title="Deduplizieren"> <Sparkles className="h-3.5 w-3.5 text-primary" />
<Sparkles className="h-4 w-4 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> </div>
{/* Graph-Sicht */} {/* Kategorie-Filter */}
<div className={cn("w-full", view === "liste" && "hidden lg:block")}> <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">
<GraphErrorBoundary> <button onClick={() => setFilter("")}
<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>}> className={cn("h-6 px-2 rounded-md text-[9px] font-semibold uppercase tracking-wider transition-all cursor-pointer shrink-0",
<GraphView data={graphData} onDelete={del} /> !filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>Alle</button>
</Suspense> {CATEGORIES.map((c) => {
</GraphErrorBoundary> 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>
</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> </div>
{/* 3. Mobiler Fallback für den Graph (unter lg) */}
<div className={cn("w-full h-[500px] relative lg:hidden", view === "liste" && "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",
"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-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} {dialogElement}
</div> </div>
) )