This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import psutil
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -18,6 +19,22 @@ def list_skills():
|
|||||||
skills_dir = os.path.join(repo_root, "deploy", "skills")
|
skills_dir = os.path.join(repo_root, "deploy", "skills")
|
||||||
skills = []
|
skills = []
|
||||||
|
|
||||||
|
# Check currently running hermes skill processes
|
||||||
|
running_skills = set()
|
||||||
|
for p in psutil.process_iter(['name', 'cmdline']):
|
||||||
|
try:
|
||||||
|
cmdline = p.info.get('cmdline')
|
||||||
|
if cmdline and any("hermes" in arg for arg in cmdline):
|
||||||
|
for arg in cmdline:
|
||||||
|
if "Führe den " in arg and " Skill aus" in arg:
|
||||||
|
# Extract skill name from "Führe den 'skill_name' Skill aus"
|
||||||
|
import re
|
||||||
|
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
|
||||||
|
if match:
|
||||||
|
running_skills.add(match.group(1))
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||||
|
pass
|
||||||
|
|
||||||
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
|
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
|
||||||
for entry in os.scandir(skills_dir):
|
for entry in os.scandir(skills_dir):
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
@@ -40,7 +57,8 @@ def list_skills():
|
|||||||
|
|
||||||
skills.append({
|
skills.append({
|
||||||
"name": entry.name,
|
"name": entry.name,
|
||||||
"description": description or "Keine Beschreibung verfügbar."
|
"description": description or "Keine Beschreibung verfügbar.",
|
||||||
|
"running": entry.name in running_skills
|
||||||
})
|
})
|
||||||
return {"skills": skills}
|
return {"skills": skills}
|
||||||
|
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
"""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:
|
|
||||||
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:
|
|
||||||
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}
|
|
||||||
@@ -9,6 +9,7 @@ import { JobsBar } from "@/components/models/JobsBar"
|
|||||||
interface Skill {
|
interface Skill {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
|
running?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkillsResp {
|
interface SkillsResp {
|
||||||
@@ -98,16 +99,16 @@ export function SkillsView() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => mutation.mutate(skill.name)}
|
onClick={() => mutation.mutate(skill.name)}
|
||||||
disabled={runningSkill === skill.name || mutation.isPending}
|
disabled={runningSkill === skill.name || skill.running || mutation.isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",
|
"shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",
|
||||||
runningSkill === skill.name
|
(runningSkill === skill.name || skill.running)
|
||||||
? "bg-muted text-muted-foreground cursor-not-allowed"
|
? "bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed"
|
||||||
: "bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"
|
: "bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{runningSkill === skill.name ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
{(runningSkill === skill.name || skill.running) ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||||
{runningSkill === skill.name ? "Startet" : "Starten"}
|
{(runningSkill === skill.name || skill.running) ? "Läuft …" : "Starten"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground flex-1">
|
<div className="text-sm text-muted-foreground flex-1">
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
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<string, string> = {
|
|
||||||
"": "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<string | null>(null)
|
|
||||||
const [doc, setDoc] = useState<WissenDatei | null>(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<string, string>()
|
|
||||||
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<WissenDatei>(`/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 (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<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">
|
|
||||||
Wissen
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
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>
|
|
||||||
|
|
||||||
{data && !data.available && (
|
|
||||||
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
|
|
||||||
Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> Vault wird geladen …
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data?.available && files.length === 0 && (
|
|
||||||
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
|
|
||||||
Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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">
|
|
||||||
{/* Datei-Liste */}
|
|
||||||
<aside className="w-full shrink-0 space-y-3 lg:w-72">
|
|
||||||
<div className="relative">
|
|
||||||
<input value={q} onChange={(e) => setQ(e.target.value)} type="search"
|
|
||||||
placeholder="Notizen durchsuchen…" aria-label="Notizen durchsuchen"
|
|
||||||
className="h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50" />
|
|
||||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin">
|
|
||||||
{groups.map(({ dir, files: gf }) => (
|
|
||||||
<div key={dir || "__root"}>
|
|
||||||
<p className="mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
|
|
||||||
<FolderOpen className="h-3 w-3" /> {DIR_LABEL[dir] ?? dir}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{gf.map((f) => (
|
|
||||||
<button key={f.path} onClick={() => setActive(f.path)}
|
|
||||||
className={cn("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",
|
|
||||||
active === f.path ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-foreground")}>
|
|
||||||
<FileText className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate" title={f.title}>{f.name}</span>
|
|
||||||
{f.neu && <span className="ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300"><Sparkles className="h-2.5 w-2.5" />neu</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Inhalt */}
|
|
||||||
<div className="min-w-0 flex-1 mc-card p-5">
|
|
||||||
{docLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /> Notiz wird geladen …</div>
|
|
||||||
) : doc ? (
|
|
||||||
<>
|
|
||||||
<div className="mb-3 flex items-center justify-between border-b border-border/40 pb-2">
|
|
||||||
<span className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground"><Library className="h-3.5 w-3.5" /> {doc.path}</span>
|
|
||||||
{doc.mtime > 0 && (
|
|
||||||
<span className="text-[10px] text-muted-foreground/60">
|
|
||||||
Stand {new Date(doc.mtime * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Markdown text={doc.content} onWikiLink={openWikiLink} known={byName} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX) ──
|
|
||||||
function Markdown({ text, onWikiLink, known }: {
|
|
||||||
text: string
|
|
||||||
onWikiLink: (name: string) => void
|
|
||||||
known: Map<string, string>
|
|
||||||
}) {
|
|
||||||
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(<ul key={key} className="mb-3 ml-4 list-disc space-y-1">{list}</ul>)
|
|
||||||
list = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.forEach((raw, i) => {
|
|
||||||
const key = `l${i}`
|
|
||||||
if (code !== null) {
|
|
||||||
if (raw.trimEnd() === "```") {
|
|
||||||
out.push(<pre key={key} className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85">{code.join("\n")}</pre>)
|
|
||||||
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(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], onWikiLink, known, key)}</p>)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const li = line.match(/^\s*[-*•]\s+(.*)$/)
|
|
||||||
if (li) {
|
|
||||||
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(li[1], onWikiLink, known, key)}</li>)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flushList(key)
|
|
||||||
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, onWikiLink, known, key)}</p>)
|
|
||||||
})
|
|
||||||
flushList("end")
|
|
||||||
if (code !== null) out.push(<pre key="code-end" className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]">{(code as string[]).join("\n")}</pre>)
|
|
||||||
|
|
||||||
return <div className="max-w-3xl">{out}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline: [[wiki-links]], **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
|
|
||||||
function inline(text: string, onWikiLink: (n: string) => void, known: Map<string, string>, 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 ? (
|
|
||||||
<button key={key} onClick={() => onWikiLink(target)}
|
|
||||||
className="rounded bg-primary/10 px-1 text-primary underline decoration-primary/40 underline-offset-2 hover:bg-primary/20 cursor-pointer">{label}</button>
|
|
||||||
) : (
|
|
||||||
<span key={key} className="rounded bg-background/40 px-1 text-muted-foreground" title="Notiz existiert (noch) nicht">{label}</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (p.startsWith("**") && p.endsWith("**")) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
|
|
||||||
if (p.startsWith("*") && p.endsWith("*") && p.length > 2) return <i key={key}>{p.slice(1, -1)}</i>
|
|
||||||
if (p.startsWith("`") && p.endsWith("`")) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-teal-300">{p.slice(1, -1)}</code>
|
|
||||||
return <span key={key}>{p}</span>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user