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:
Hitonabi
2026-06-27 20:30:17 +02:00
parent 5c3f50dfa5
commit 56243e1835
15 changed files with 5360 additions and 130 deletions
+6
View File
@@ -36,6 +36,12 @@ def export() -> dict:
return memory.export_text()
@router.get("/memory/graph")
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
return memory.graph(min_score=min_score, top_k=top_k)
@router.post("/memory/learn", status_code=201)
def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
+5
View File
@@ -84,6 +84,11 @@ def learn(text: str | None = None, messages: list[dict] | None = None,
"source": source, "category": category})
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
return _get("/graph", min_score=min_score, top_k=top_k)
def export_text() -> dict:
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
lines = ["# Mission Control — Gedächtnis\n"]
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="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BohGf1r3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-SuFuJOVE.css">
<script type="module" crossorigin src="/assets/index-DuvhfsJu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEncQp-z.css">
</head>
<body>
<div id="root"></div>
+1004 -3
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -15,6 +15,7 @@
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"reagraph": "^4.22.0",
"recharts": "^3.9.0",
"tailwind-merge": "^2.5.5"
},
+11
View File
@@ -270,6 +270,17 @@ export interface Memory {
score?: number // Relevanz bei semantischer Suche (q gesetzt); sonst undefined
}
export interface MemoryGraphNode {
id: string
content: string
category: string
source: string
}
export interface MemoryGraph {
nodes: MemoryGraphNode[]
edges: { source: string; target: string; weight: number }[]
}
export interface DedupeResult {
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
duplicate_count: number
+9
View File
@@ -13,6 +13,7 @@ import {
type Health,
type Job,
type Memory,
type MemoryGraph,
type ModelsResp,
type RoutingResp,
type ServicesResp,
@@ -37,8 +38,16 @@ export const qk = {
drafts: (target?: string) => ["drafts", target ?? ""] as const,
connect: (params?: string) => ["connect", params ?? ""] as const,
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
memoryGraph: ["memory-graph"] as const,
}
export const useMemoryGraph = (enabled = true) =>
useQuery({
queryKey: qk.memoryGraph,
queryFn: () => api<MemoryGraph>("/api/memory/graph"),
enabled,
})
export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 })
+21
View File
@@ -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
}
}
+118
View File
@@ -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>
)
}
+50 -12
View File
@@ -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>
+54
View File
@@ -223,6 +223,60 @@ def delete_memory(mid: str) -> dict:
return {"ok": True}
@app.get("/graph")
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Graph: Knoten = Fakten, Kanten = semantische Ähnlichkeit (Kosinus
der gespeicherten Embeddings, je Knoten die top_k Nachbarn min_score). Für die
Obsidian-artige Gedächtnis-Visualisierung im UI."""
items = mem().get_all(filters={"user_id": USER_ID}, top_k=2000).get("results", [])
nodes = [_to_item(r) for r in items]
node_ids = [n["id"] for n in nodes]
idx = {nid: i for i, nid in enumerate(node_ids)}
# Embeddings direkt aus der Chroma-Collection ziehen (kein Re-Embedding).
col = mem().vector_store.collection
raw = col.get(include=["embeddings"])
raw_ids = raw.get("ids") or []
raw_embs = raw.get("embeddings")
edges: list[dict] = []
vecs = [None] * len(node_ids)
have = 0
if raw_embs is not None:
for rid, emb in zip(raw_ids, raw_embs):
if rid in idx and emb is not None:
vecs[idx[rid]] = emb
have += 1
if have >= 2:
import numpy as np
present = [i for i, v in enumerate(vecs) if v is not None]
M = np.array([vecs[i] for i in present], dtype=float)
norms = np.linalg.norm(M, axis=1, keepdims=True)
norms[norms == 0] = 1.0
Mn = M / norms
sim = Mn @ Mn.T
seen: set[tuple[int, int]] = set()
for a in range(len(present)):
order = np.argsort(-sim[a])
cnt = 0
for b in order:
if b == a:
continue
s = float(sim[a][b])
if s < min_score:
break
i, j = present[a], present[b]
key = (min(i, j), max(i, j))
if key not in seen:
seen.add(key)
edges.append({"source": node_ids[i], "target": node_ids[j],
"weight": round(s, 3)})
cnt += 1
if cnt >= top_k:
break
return {"nodes": nodes, "edges": edges}
@app.post("/learn")
def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 EXTRAHIERT Fakten selbst