diff --git a/backend/app.py b/backend/app.py index cccac41..487627a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -36,6 +36,7 @@ from routers import ( skills, system, voice, + wissen, zeitmaschine, ) from routers import reminders as reminders_router @@ -129,6 +130,7 @@ app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Kl app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store) app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus +app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached) app.include_router(skills.router) # Skills & Jobs Dashboard app.include_router( diff --git a/backend/routers/wissen.py b/backend/routers/wissen.py new file mode 100644 index 0000000..26cac1f --- /dev/null +++ b/backend/routers/wissen.py @@ -0,0 +1,151 @@ +"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares +Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron +(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert.""" + +import os +import re +import time +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +router = APIRouter(prefix="/api") + +VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser() + + +def _available() -> bool: + return VAULT.is_dir() + + +def _title_of(p: Path) -> str: + """Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename.""" + try: + with p.open(encoding="utf-8", errors="replace") as fh: + for line in fh: + s = line.strip() + if s: + return s.lstrip("# ").strip()[:160] + except OSError: + pass + return p.stem + + +class VaultFile(BaseModel): + path: str + name: str + dir: str + title: str + mtime: float + neu: bool + + +class WissenListResponse(BaseModel): + available: bool + files: list[VaultFile] + + +class WissenFileResponse(BaseModel): + path: str + content: str + mtime: float + + +@router.get("/wissen", response_model=WissenListResponse) +def list_vault() -> dict: + """Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter).""" + if not _available(): + return {"available": False, "files": []} + files = [] + now = time.time() + for p in sorted(VAULT.rglob("*.md")): + if ".git" in p.parts or "traeume" in p.parts: + continue + rel = p.relative_to(VAULT).as_posix() + st = p.stat() + files.append({ + "path": rel, + "name": p.stem, + "dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "", + "title": _title_of(p), + "mtime": st.st_mtime, + "neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht" + }) + files.sort(key=lambda f: f["mtime"], reverse=True) + return {"available": True, "files": files} + + +@router.get("/wissen/datei", response_model=WissenFileResponse) +def read_file(pfad: str) -> dict: + """Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to).""" + if not _available(): + raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).") + try: + target = (VAULT / pfad).resolve() + if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file(): + raise HTTPException(404, "Notiz nicht gefunden.") + return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000], + "mtime": target.stat().st_mtime} + except HTTPException: + raise + except (ValueError, OSError): + 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 or "traeume" 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, + "content": p.stem, # stem ist oft kürzer/prägnanter als der Titel + "category": cat, + "source": "wiki" + }) + + # 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 = {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} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7d14e1..ebb3284 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ const IdeenView = lazy(() => import("@/views/IdeenView").then((m) => ({ default: const SkillsView = lazy(() => import("@/views/SkillsView").then((m) => ({ default: m.SkillsView }))) const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView }))) +const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView }))) // Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar). function ViewLoading() { @@ -206,6 +207,7 @@ export default function App() { {view === "skills" && } {view === "models" && } {view === "connect" && } + {view === "wissen" && } {view === "chronik" && } {view === "agent" && } diff --git a/frontend/src/nav.ts b/frontend/src/nav.ts index 292f870..89d4236 100644 --- a/frontend/src/nav.ts +++ b/frontend/src/nav.ts @@ -3,6 +3,7 @@ import { Boxes, Inbox, History, + Library, Plug, Bot, SquareTerminal, @@ -11,7 +12,7 @@ import { type LucideIcon, } from "lucide-react" -export type ViewId = "dashboard" | "ideen" | "auftraege" | "skills" | "models" | "chronik" | "connect" | "agent" | "konsole" | "guide" +export type ViewId = "dashboard" | "ideen" | "auftraege" | "skills" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide" export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung" @@ -34,6 +35,7 @@ export const NAV: NavItem[] = [ { id: "auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" }, { id: "skills", label: "Skills & Jobs", hint: "Autonome Skills manuell auslösen", icon: Bot, group: "Operativ" }, { id: "chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" }, + { id: "wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" }, { id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" }, { id: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" }, { id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle, group: "Werkzeuge" }, diff --git a/frontend/src/views/WissenView.tsx b/frontend/src/views/WissenView.tsx new file mode 100644 index 0000000..fcbf697 --- /dev/null +++ b/frontend/src/views/WissenView.tsx @@ -0,0 +1,283 @@ +import { useEffect, useMemo, useState } from "react" +import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react" +import { api, type WissenDatei, type WissenFile } from "@/lib/api" +import { useWissen, useMemoryGraph } from "@/lib/queries" +import { GraphView } from "./GraphView" +import { GraphErrorBoundary } from "./GraphErrorBoundary" +import { cn } from "@/lib/utils" + +// Wissens-Vault: die nächtlichen Traum-Notizen der Box als klickbares Wiki. +// [[wiki-links]] springen zur verlinkten Notiz; Markdown wird leichtgewichtig gerendert +// (bewusst ohne externe Markdown-Lib — Überschriften, Listen, fett/kursiv, Code reichen hier). + +const DIR_LABEL: Record = { + "": "Allgemein", + traeume: "Träume", + muster: "Muster", + "skill-kandidaten": "Skill-Kandidaten", + "skill-kandidaten/beauftragt": "Skill-Kandidaten · beauftragt", + "skill-kandidaten/verworfen": "Skill-Kandidaten · verworfen", +} + +export function WissenView() { + const { data, isLoading } = useWissen() + const [active, setActive] = useState(null) + const [doc, setDoc] = useState(null) + const [docLoading, setDocLoading] = useState(false) + const [q, setQ] = useState("") + const [showGraph, setShowGraph] = useState(false) + + const { data: graphData, isLoading: graphLoading } = useMemoryGraph(showGraph) + + const files = useMemo(() => data?.files ?? [], [data]) + + // Name → Pfad für [[wiki-links]] (Dateiname ohne .md, case-insensitive). + const byName = useMemo(() => { + const m = new Map() + for (const f of files) m.set(f.name.toLowerCase(), f.path) + return m + }, [files]) + + const filtered = useMemo(() => { + if (!q.trim()) return files + const ql = q.toLowerCase() + return files.filter((f) => f.title.toLowerCase().includes(ql) || f.path.toLowerCase().includes(ql)) + }, [files, q]) + + const groups = useMemo(() => { + const g: { dir: string; files: WissenFile[] }[] = [] + for (const f of filtered) { + const found = g.find((x) => x.dir === f.dir) + if (found) found.files.push(f) + else g.push({ dir: f.dir, files: [f] }) + } + // INDEX zuerst, dann Träume, dann Rest alphabetisch + return g.sort((a, b) => (a.dir === "" ? -1 : b.dir === "" ? 1 : a.dir.localeCompare(b.dir))) + }, [filtered]) + + // Beim ersten Laden: INDEX.md öffnen, falls vorhanden. + useEffect(() => { + if (!active && files.length) { + const index = files.find((f) => f.path.toLowerCase() === "index.md") + setActive(index?.path ?? files[0].path) + } + }, [files, active]) + + useEffect(() => { + if (!active) return + setDocLoading(true) + api(`/api/wissen/datei?pfad=${encodeURIComponent(active)}`) + .then(setDoc) + .catch(() => setDoc({ path: active, content: "*(Notiz nicht ladbar)*", mtime: 0 })) + .finally(() => setDocLoading(false)) + }, [active]) + + const openWikiLink = (name: string) => { + const path = byName.get(name.toLowerCase()) + if (path) setActive(path) + } + + return ( +
+
+
+

+ Wissen +

+

+ Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki. +

+
+ {files.length > 0 && ( +
+ + +
+ )} +
+ + {data && !data.available && ( +
+ Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar. +
+ )} + + {isLoading && ( +
+ Vault wird geladen … +
+ )} + + {data?.available && files.length === 0 && ( +
+ Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste. +
+ )} + + {showGraph ? ( +
+ {graphLoading && ( +
+
+ Graph wird berechnet … +
+
+ )} + + {graphData && ( + { + if (id) { + setActive(id) + setShowGraph(false) + } + }} + /> + )} + +
+ ) : files.length > 0 && ( +
+ {/* Datei-Liste */} + + + {/* Inhalt */} +
+ {docLoading ? ( +
Notiz wird geladen …
+ ) : doc ? ( + <> +
+ {doc.path} + {doc.mtime > 0 && ( + + Stand {new Date(doc.mtime * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })} + + )} +
+ + + ) : null} +
+
+ )} +
+ ) +} + +// ── Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX) ── +function Markdown({ text, onWikiLink, known }: { + text: string + onWikiLink: (name: string) => void + known: Map +}) { + const lines = text.split("\n") + const out: React.ReactNode[] = [] + let list: React.ReactNode[] = [] + let code: string[] | null = null + + const flushList = (key: string) => { + if (list.length) { + out.push(
    {list}
) + list = [] + } + } + + lines.forEach((raw, i) => { + const key = `l${i}` + if (code !== null) { + if (raw.trimEnd() === "```") { + out.push(
{code.join("\n")}
) + code = null + } else code.push(raw) + return + } + if (raw.trimStart().startsWith("```")) { flushList(key); code = []; return } + + const line = raw.trimEnd() + if (!line.trim()) { flushList(key); return } + + const h = line.match(/^(#{1,4})\s+(.*)$/) + if (h) { + flushList(key) + const level = h[1].length + const cls = level === 1 ? "text-lg font-bold mt-1 mb-3" : level === 2 ? "text-base font-bold mt-4 mb-2" : "text-sm font-bold mt-3 mb-1.5" + out.push(

{inline(h[2], onWikiLink, known, key)}

) + return + } + const li = line.match(/^\s*[-*•]\s+(.*)$/) + if (li) { + list.push(
  • {inline(li[1], onWikiLink, known, key)}
  • ) + return + } + flushList(key) + out.push(

    {inline(line, onWikiLink, known, key)}

    ) + }) + flushList("end") + if (code !== null) out.push(
    {(code as string[]).join("\n")}
    ) + + return
    {out}
    +} + +// Inline: [[wiki-links]], **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject). +function inline(text: string, onWikiLink: (n: string) => void, known: Map, keyBase: string): React.ReactNode[] { + const parts = text.split(/(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g) + return parts.map((p, i) => { + const key = `${keyBase}-${i}` + const wiki = p.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/) + if (wiki) { + const target = wiki[1].trim() + const label = (wiki[2] ?? wiki[1]).trim() + const exists = known.has(target.toLowerCase()) + return exists ? ( + + ) : ( + {label} + ) + } + if (p.startsWith("**") && p.endsWith("**")) return {p.slice(2, -2)} + if (p.startsWith("*") && p.endsWith("*") && p.length > 2) return {p.slice(1, -1)} + if (p.startsWith("`") && p.endsWith("`")) return {p.slice(1, -1)} + return {p} + }) +}