This commit is contained in:
@@ -9,6 +9,7 @@ import { JobsBar } from "@/components/models/JobsBar"
|
||||
interface Skill {
|
||||
name: string
|
||||
description: string
|
||||
running?: boolean
|
||||
}
|
||||
|
||||
interface SkillsResp {
|
||||
@@ -98,16 +99,16 @@ export function SkillsView() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => mutation.mutate(skill.name)}
|
||||
disabled={runningSkill === skill.name || mutation.isPending}
|
||||
disabled={runningSkill === skill.name || skill.running || mutation.isPending}
|
||||
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",
|
||||
runningSkill === skill.name
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
(runningSkill === skill.name || skill.running)
|
||||
? "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"
|
||||
)}
|
||||
>
|
||||
{runningSkill === skill.name ? <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) ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
{(runningSkill === skill.name || skill.running) ? "Läuft …" : "Starten"}
|
||||
</button>
|
||||
</div>
|
||||
<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