Feat: Gedaechtnis-Graph-Ansicht (Reagraph) + /api/memory/graph
Obsidian-artige Visualisierung des Gedaechtnisses: Knoten = Fakten, Kanten = semantische Aehnlichkeit (Kosinus der gespeicherten Embeddings, kNN je Knoten), Farbe = Kategorie, Groesse = Vernetzung. Klick auf Knoten -> Detailpanel mit verwandten Fakten + vergessen. - mem0_service/app.py: /graph rechnet Aehnlichkeitskanten aus den Chroma-Embeddings. - backend: services.memory.graph() + /api/memory/graph (Passthrough). - frontend: GraphView (reagraph, WebGL), Graph/Liste-Umschalter in MemoryView, GraphErrorBoundary, lazy-load (three.js nur bei Bedarf -> Hauptbundle bleibt schlank). reagraph auf 4.22.0 gepinnt (4.23+ braucht @react-three/fiber v9 = React 19; Projekt ist React 18). Live gegen die Box verifiziert (Graph rendert, Kategorien-Farben, Kanten, dunkel). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Component, type ReactNode } from "react"
|
||||
|
||||
export class GraphErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto">
|
||||
<div className="font-semibold mb-2">Graph konnte nicht gerendert werden</div>
|
||||
<pre className="whitespace-pre-wrap break-words text-[11px] leading-relaxed">{this.state.error.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { GraphCanvas, darkTheme } from "reagraph"
|
||||
import { Trash2, Sparkles, Share2 } from "lucide-react"
|
||||
import { type MemoryGraph } from "@/lib/api"
|
||||
|
||||
const CAT_COLOR: Record<string, string> = {
|
||||
user: "#22d3ee", instruction: "#a78bfa", stable: "#6366f1", versioned: "#fbbf24", ephemeral: "#f472b6",
|
||||
}
|
||||
const CAT_LABEL: Record<string, string> = {
|
||||
user: "User", instruction: "Regel", stable: "Fakt", versioned: "Version", ephemeral: "Temporär",
|
||||
}
|
||||
const AUTO = new Set(["auto", "agent", "hermes"])
|
||||
|
||||
export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const degree = useMemo(() => {
|
||||
const d: Record<string, number> = {}
|
||||
data.edges.forEach((e) => { d[e.source] = (d[e.source] || 0) + 1; d[e.target] = (d[e.target] || 0) + 1 })
|
||||
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])
|
||||
|
||||
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 sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
|
||||
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])
|
||||
|
||||
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 (
|
||||
<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>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 min-h-[480px]">
|
||||
{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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
Einen Knoten wählen, um den Fakt und seine semantischen Nachbarn zu sehen.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState } from "react"
|
||||
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen } from "lucide-react"
|
||||
import { useState, lazy, Suspense } from "react"
|
||||
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen, Share2, List } from "lucide-react"
|
||||
import { api, type DedupeResult } from "@/lib/api"
|
||||
import { useMemory, useQueryClient } from "@/lib/queries"
|
||||
import { useMemory, useMemoryGraph, useQueryClient } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { GraphErrorBoundary } from "./GraphErrorBoundary"
|
||||
|
||||
// reagraph + three.js sind schwer → erst laden, wenn der Graph-Tab geöffnet wird.
|
||||
const GraphView = lazy(() => import("./GraphView").then((m) => ({ default: m.GraphView })))
|
||||
|
||||
|
||||
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
|
||||
|
||||
@@ -35,13 +40,18 @@ export function MemoryView() {
|
||||
const [content, setContent] = useState("")
|
||||
const [category, setCategory] = useState("stable")
|
||||
const [deduping, setDeduping] = useState(false)
|
||||
const [view, setView] = useState<"liste" | "graph">("liste")
|
||||
|
||||
const qc = useQueryClient()
|
||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||
const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
|
||||
const { data: graph } = useMemoryGraph(view === "graph")
|
||||
const error = itemsErr ? String(itemsErr) : ""
|
||||
|
||||
const reloadMemory = () => qc.invalidateQueries({ queryKey: ["memory"] })
|
||||
const reloadMemory = () => {
|
||||
qc.invalidateQueries({ queryKey: ["memory"] })
|
||||
qc.invalidateQueries({ queryKey: ["memory-graph"] })
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!content.trim()) return
|
||||
@@ -104,14 +114,32 @@ export function MemoryView() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={cleanup}
|
||||
disabled={deduping}
|
||||
className="flex h-9 px-4 items-center gap-1.5 text-xs font-semibold rounded-lg border border-border/60 bg-card hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50 shrink-0 self-start"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary animate-pulse" />
|
||||
<span>Deduplizieren</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 shrink-0 self-start">
|
||||
<div className="flex items-center gap-1 p-1 bg-card/30 border border-border/40 rounded-lg">
|
||||
<button
|
||||
onClick={() => setView("liste")}
|
||||
className={cn("flex h-7 px-2.5 items-center gap-1.5 text-[11px] font-semibold rounded-md transition-all cursor-pointer",
|
||||
view === "liste" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" /> Liste
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("graph")}
|
||||
className={cn("flex h-7 px-2.5 items-center gap-1.5 text-[11px] font-semibold rounded-md transition-all cursor-pointer",
|
||||
view === "graph" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" /> Graph
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={cleanup}
|
||||
disabled={deduping}
|
||||
className="flex h-9 px-4 items-center gap-1.5 text-xs font-semibold 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 animate-pulse" />
|
||||
<span>Deduplizieren</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Fact Box */}
|
||||
@@ -151,6 +179,14 @@ export function MemoryView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "graph" ? (
|
||||
<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={graph ?? { nodes: [], edges: [] }} onDelete={del} />
|
||||
</Suspense>
|
||||
</GraphErrorBoundary>
|
||||
) : (
|
||||
<>
|
||||
{/* Filter / Search HUD */}
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
@@ -264,6 +300,8 @@ export function MemoryView() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user