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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user