Feat: Gedächtnis-Tab überarbeitet — Sigma.js-Graph (skaliert), Liste als Default + gruppiert

Graph: reagraph (three.js, schwer, hing Renderer) → Sigma.js v3 + graphology (graph-optimiertes WebGL,
skaliert auf tausende Knoten), im App-Look: Kategorie-Farben, Knotengröße nach Verknüpfungen,
Hover-Highlight (Nachbarn hervor, Rest dimmt), Legende. Liste ist jetzt Default + nach Kategorie
gruppierte Sektionen (statt flacher Wand). reagraph deinstalliert → leichteres Bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 11:09:33 +02:00
parent 7bc20a6302
commit d832f90ad6
16 changed files with 958 additions and 4808 deletions
+90 -35
View File
@@ -1,8 +1,13 @@
import { useMemo, useState } from "react"
import { GraphCanvas, darkTheme } from "reagraph"
import { useEffect, useMemo, useRef, useState } from "react"
import Graph from "graphology"
import forceAtlas2 from "graphology-layout-forceatlas2"
import Sigma from "sigma"
import { Trash2, Sparkles, Share2 } from "lucide-react"
import { type MemoryGraph } from "@/lib/api"
// Sigma.js v3 + graphology: graph-optimiertes WebGL (kein three.js-Ballast) → skaliert auf tausende
// Fakten flüssig. Im App-Look gestylt; Hover hebt den Knoten + seine Nachbarn hervor, der Rest dimmt.
const CAT_COLOR: Record<string, string> = {
identity: "#22d3ee", knowledge: "#6366f1", rules: "#a78bfa", events: "#fbbf24",
}
@@ -10,9 +15,18 @@ const CAT_LABEL: Record<string, string> = {
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
}
const AUTO = new Set(["auto", "agent", "hermes"])
const DIM_NODE = "#1f2937"
const DIM_EDGE = "#0f172a"
const EDGE = "#243044"
const EDGE_HI = "#4b5563"
export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) {
const [selected, setSelected] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const sigmaRef = useRef<Sigma | null>(null)
const graphRef = useRef<Graph | null>(null)
const active = useRef<string | null>(null) // hervorgehobener Knoten (hover oder Auswahl)
const selectedRef = useRef<string | null>(null) // aktuelle Auswahl (für leaveNode ohne Effekt-Neulauf)
const degree = useMemo(() => {
const d: Record<string, number> = {}
@@ -20,19 +34,67 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
return d
}, [data])
const nodes = useMemo(() => data.nodes.map((n) => ({
id: n.id,
label: n.content.length > 26 ? n.content.slice(0, 25) + "…" : n.content,
fill: CAT_COLOR[n.category] || "#64748b",
size: 6 + Math.min(degree[n.id] || 0, 6) * 2,
})), [data, degree])
// Graph (neu)bauen + Layout + Sigma rendern, wenn sich die Daten ändern.
useEffect(() => {
if (!containerRef.current || !data.nodes.length) return
const graph = new Graph()
const N = data.nodes.length
data.nodes.forEach((n, i) => {
const a = (2 * Math.PI * i) / N
graph.addNode(n.id, {
x: Math.cos(a), y: Math.sin(a), // Startposition (Kreis) für ForceAtlas2
size: 4 + Math.min(degree[n.id] || 0, 12) * 1.4,
color: CAT_COLOR[n.category] || "#64748b",
label: n.content.length > 48 ? n.content.slice(0, 47) + "…" : n.content,
})
})
data.edges.forEach((e) => {
if (graph.hasNode(e.source) && graph.hasNode(e.target) && !graph.hasEdge(e.source, e.target))
graph.addEdge(e.source, e.target, { size: 0.6 + (e.weight || 0), color: EDGE })
})
forceAtlas2.assign(graph, { iterations: 220, settings: forceAtlas2.inferSettings(graph) })
graphRef.current = graph
const edges = useMemo(() => data.edges.map((e) => ({
id: `${e.source}->${e.target}`,
source: e.source,
target: e.target,
size: 0.4 + e.weight,
})), [data])
const renderer = new Sigma(graph, containerRef.current, {
renderLabels: true,
labelColor: { color: "#cbd5e1" },
labelSize: 11,
labelWeight: "500",
labelDensity: 0.6,
labelRenderedSizeThreshold: N > 120 ? 12 : 0, // bei vielen Knoten nur große labeln (entklumpen)
defaultEdgeColor: EDGE,
minCameraRatio: 0.1,
maxCameraRatio: 4,
nodeReducer: (id, attrs) => {
const a = active.current
if (!a) return attrs
if (id === a || graph.areNeighbors(a, id)) return attrs
return { ...attrs, color: DIM_NODE, label: "" }
},
edgeReducer: (edge, attrs) => {
const a = active.current
if (!a) return attrs
const ext = graph.extremities(edge)
if (ext[0] === a || ext[1] === a) return { ...attrs, color: EDGE_HI, size: (attrs.size || 1) * 1.6 }
return { ...attrs, color: DIM_EDGE }
},
})
sigmaRef.current = renderer
renderer.on("clickNode", ({ node }) => setSelected(node))
renderer.on("clickStage", () => setSelected(null))
renderer.on("enterNode", ({ node }) => { active.current = node; renderer.refresh(); containerRef.current!.style.cursor = "pointer" })
renderer.on("leaveNode", () => { active.current = selectedRef.current; renderer.refresh(); containerRef.current!.style.cursor = "default" })
return () => { renderer.kill(); sigmaRef.current = null; graphRef.current = null }
}, [data, degree])
// Auswahl spiegelt die Hervorhebung (auch ohne Hover).
useEffect(() => {
selectedRef.current = selected
active.current = selected
sigmaRef.current?.refresh()
}, [selected])
const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
const neighbors = useMemo(() => {
@@ -56,17 +118,15 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-3">
<div className="relative h-[480px] rounded-2xl border border-border/60 bg-[#070a0f] overflow-hidden">
<GraphCanvas
nodes={nodes}
edges={edges}
theme={darkTheme}
layoutType="forceDirected2d"
labelType="nodes"
edgeArrowPosition="none"
draggable
onNodeClick={(n: any) => setSelected(n.id)}
onCanvasClick={() => setSelected(null)}
/>
<div ref={containerRef} className="absolute inset-0" />
{/* Legende */}
<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">
{Object.entries(CAT_LABEL).map(([k, l]) => (
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
</span>
))}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 min-h-[480px]">
@@ -87,20 +147,15 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
</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"
>
<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">
<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"
>
<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">
<Trash2 className="h-3.5 w-3.5" /> vergessen
</button>
</>
@@ -108,7 +163,7 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
<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">
Einen Knoten wählen, um den Fakt und seine semantischen Nachbarn zu sehen.
Auf einen Knoten klicken, um den Fakt und seine semantischen Nachbarn zu sehen. Hover hebt das Netz hervor.
</div>
</div>
)}