feat: obsidian-style graph view for wissens-vault

This commit is contained in:
Hitonabi
2026-08-07 12:16:01 +02:00
parent 327d5a495e
commit 8f359ef4c9
3 changed files with 116 additions and 10 deletions
+58
View File
@@ -3,6 +3,7 @@ Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom T
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert.""" (und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
import os import os
import re
import time import time
from pathlib import Path from pathlib import Path
@@ -90,3 +91,60 @@ def read_file(pfad: str) -> dict:
raise raise
except (ValueError, OSError): except (ValueError, OSError):
raise HTTPException(404, "Notiz nicht lesbar.") raise HTTPException(404, "Notiz nicht lesbar.")
@router.get("/wissen/graph")
def graph_vault() -> dict:
"""Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links)."""
if not _available():
return {"nodes": [], "edges": []}
nodes = []
edges = []
# Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID)
name_to_id = {}
# 1. Alle Nodes sammeln
for p in VAULT.rglob("*.md"):
if ".git" in p.parts:
continue
rel = p.relative_to(VAULT).as_posix()
name_to_id[p.stem.lower()] = rel
# Kategorie aus dem Ordner ableiten
cat = "knowledge"
parent = p.parent.name if p.parent != VAULT else ""
if parent == "traeume":
cat = "events"
elif parent == "muster":
cat = "rules"
elif parent == "skill-kandidaten":
cat = "identity"
nodes.append({
"id": rel,
"label": p.stem, # stem ist oft kürzer/prägnanter als der Titel
"category": cat
})
# 2. Edges extrahieren (Wiki-Links [[Name]])
link_rx = re.compile(r"\[\[([^\]]+)\]\]")
for n in nodes:
try:
target = VAULT / n["id"]
content = target.read_text(encoding="utf-8", errors="replace")
# Set, um mehrfache Links auf dieselbe Datei zu entduplizieren
found_links = set(m.group(1).strip().lower() for m in link_rx.finditer(content))
for link in found_links:
target_id = name_to_id.get(link)
if target_id and target_id != n["id"]:
edges.append({
"source": n["id"],
"target": target_id,
"weight": 1.0
})
except OSError:
pass
return {"nodes": nodes, "edges": edges}
+2 -1
View File
@@ -172,8 +172,9 @@ export const useGovernor = (refetchInterval: number = TAKT.normal) =>
export const useMemoryGraph = (enabled = true) => export const useMemoryGraph = (enabled = true) =>
useQuery({ useQuery({
queryKey: qk.memoryGraph, queryKey: qk.memoryGraph,
queryFn: () => api<MemoryGraph>("/api/memory/graph"), queryFn: () => api<MemoryGraph>("/api/wissen/graph"),
enabled, enabled,
staleTime: 60 * 1000,
}) })
export const useHealth = () => export const useHealth = () =>
+56 -9
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react" import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react"
import { api, type WissenDatei, type WissenFile } from "@/lib/api" import { api, type WissenDatei, type WissenFile } from "@/lib/api"
import { useWissen } from "@/lib/queries" import { useWissen, useMemoryGraph } from "@/lib/queries"
import { GraphView } from "./GraphView"
import { GraphErrorBoundary } from "./GraphErrorBoundary"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Wissens-Vault: die nächtlichen Traum-Notizen der Box als klickbares Wiki. // Wissens-Vault: die nächtlichen Traum-Notizen der Box als klickbares Wiki.
@@ -23,6 +25,9 @@ export function WissenView() {
const [doc, setDoc] = useState<WissenDatei | null>(null) const [doc, setDoc] = useState<WissenDatei | null>(null)
const [docLoading, setDocLoading] = useState(false) const [docLoading, setDocLoading] = useState(false)
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [showGraph, setShowGraph] = useState(false)
const { data: graphData, isLoading: graphLoading } = useMemoryGraph(showGraph)
const files = useMemo(() => data?.files ?? [], [data]) const files = useMemo(() => data?.files ?? [], [data])
@@ -74,13 +79,31 @@ export function WissenView() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div> <div className="flex items-center justify-between">
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent"> <div>
Wissen <h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
</h1> Wissen
<p className="text-sm text-muted-foreground"> </h1>
Was sich die Box nachts erschließt Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki. <p className="text-sm text-muted-foreground">
</p> Was sich die Box nachts erschließt Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki.
</p>
</div>
{files.length > 0 && (
<div className="flex rounded-lg border border-border/60 bg-card p-1">
<button
onClick={() => setShowGraph(false)}
className={cn("rounded-md px-3 py-1.5 text-xs font-medium transition-colors", !showGraph ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:bg-muted")}
>
Liste
</button>
<button
onClick={() => setShowGraph(true)}
className={cn("rounded-md px-3 py-1.5 text-xs font-medium transition-colors", showGraph ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:bg-muted")}
>
Graph
</button>
</div>
)}
</div> </div>
{data && !data.available && ( {data && !data.available && (
@@ -101,7 +124,31 @@ export function WissenView() {
</div> </div>
)} )}
{files.length > 0 && ( {showGraph ? (
<div className="mc-card flex h-[70vh] flex-col overflow-hidden p-0 relative">
{graphLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card p-6 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Graph wird berechnet
</div>
</div>
)}
<GraphErrorBoundary>
{graphData && (
<GraphView
data={graphData}
selectedNodeId={active}
onNodeSelect={(id) => {
if (id) {
setActive(id)
setShowGraph(false)
}
}}
/>
)}
</GraphErrorBoundary>
</div>
) : files.length > 0 && (
<div className="flex flex-col gap-4 lg:flex-row"> <div className="flex flex-col gap-4 lg:flex-row">
{/* Datei-Liste */} {/* Datei-Liste */}
<aside className="w-full shrink-0 space-y-3 lg:w-72"> <aside className="w-full shrink-0 space-y-3 lg:w-72">