Feat: Gedaechtnis-Seite graph-first ueberarbeitet + Starter-Vorlage

Kompletter Umbau des Memory-Tabs: Graph ist jetzt Standard/Held statt sekundaerer
Toggle. Kompakte Werkzeugleiste (semantische Suche, "+ Eintrag" als Button statt
Riesenbox, Liste/Graph-Umschalter, Dedup). Statuszeile mit Zaehlern (Fakten, auto
gelernt, manuell, Kategorien). Suche filtert auch den Graphen (clientseitig).

Empty State mit "Starter-Vorlage" (Beginner Template): auf Knopfdruck fuegt der
Nutzer 6 kuratierte Beispiel-Fakten ueber den Stack ein (source=template, einzeln
editier-/loeschbar) - nichts wird automatisch injiziert. Vorschau der Vorlage sichtbar.

Live gegen die Box verifiziert (Empty State, Template-Flow, Graph-Render, Stats).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 20:42:43 +02:00
parent 56243e1835
commit 00209dedff
7 changed files with 741 additions and 719 deletions
@@ -1,4 +1,4 @@
import{r as K,a as Hv,g as Hr,R as Um,c as q2,j as Ne,l as Z2,S as K2,b as Vx,T as $2}from"./index-DuvhfsJu.js";/**
import{r as K,a as Hv,g as Hr,R as Um,c as q2,j as Ne,l as Z2,S as K2,b as Vx,T as $2}from"./index-D7EBqqTV.js";/**
* @license
* Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT
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-DuvhfsJu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEncQp-z.css">
<script type="module" crossorigin src="/assets/index-D7EBqqTV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DNH8F21o.css">
</head>
<body>
<div id="root"></div>
+248 -231
View File
@@ -1,49 +1,60 @@
import { useState, lazy, Suspense } from "react"
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen, Share2, List } from "lucide-react"
import { useState, useMemo, lazy, Suspense } from "react"
import {
Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen,
Share2, List, Brain, X, Wand2,
} from "lucide-react"
import { api, type DedupeResult } from "@/lib/api"
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"]
// Herkunft: automatisch gelernt (Mem0-Extraktion / Agent) vs. manuell angelegt.
const AUTO_SOURCES = new Set(["auto", "agent"])
// Herkunft: automatisch gelernt (Mem0-Extraktion / Agent / Hermes) vs. manuell/Vorlage.
const AUTO_SOURCES = new Set(["auto", "agent", "hermes"])
const CAT_CONFIG: Record<string, { label: string; icon: any; color: string; border: string; bg: string; text: string }> = {
user: { label: "User", icon: User, color: "text-cyan-400", border: "border-cyan-500/30", bg: "bg-cyan-500/10", text: "text-cyan-400" },
instruction: { label: "Regel", icon: Scroll, color: "text-violet-400", border: "border-violet-500/30", bg: "bg-violet-500/10", text: "text-violet-400" },
stable: { label: "Fakt", icon: Shield, color: "text-indigo-400", border: "border-indigo-500/30", bg: "bg-indigo-500/10", text: "text-indigo-400" },
versioned: { label: "Version", icon: Tag, color: "text-amber-400", border: "border-amber-500/30", bg: "bg-amber-500/10", text: "text-amber-400" },
ephemeral: { label: "Temporär", icon: Clock, color: "text-pink-400", border: "border-pink-500/30", bg: "bg-pink-500/10", text: "text-pink-400" },
const CAT_CONFIG: Record<string, { label: string; icon: any; bg: string; text: string }> = {
user: { label: "User", icon: User, bg: "bg-cyan-500/10", text: "text-cyan-400" },
instruction: { label: "Regel", icon: Scroll, bg: "bg-violet-500/10", text: "text-violet-400" },
stable: { label: "Fakt", icon: Shield, bg: "bg-indigo-500/10", text: "text-indigo-400" },
versioned: { label: "Version", icon: Tag, bg: "bg-amber-500/10", text: "text-amber-400" },
ephemeral: { label: "Temporär", icon: Clock, bg: "bg-pink-500/10", text: "text-pink-400" },
}
const DEFAULT_CAT = { label: "Gedächtnis", icon: BookOpen, color: "text-muted-foreground", border: "border-border/40", bg: "bg-muted/10", text: "text-muted-foreground" }
const DEFAULT_CAT = { label: "Gedächtnis", icon: BookOpen, bg: "bg-muted/10", text: "text-muted-foreground" }
const BORDER_CLASSES: Record<string, string> = {
user: "border-l-cyan-500/80",
instruction: "border-l-violet-500/80",
stable: "border-l-indigo-500/80",
versioned: "border-l-amber-500/80",
ephemeral: "border-l-pink-500/80",
user: "border-l-cyan-500/80", instruction: "border-l-violet-500/80", stable: "border-l-indigo-500/80",
versioned: "border-l-amber-500/80", ephemeral: "border-l-pink-500/80",
}
// Starter-Vorlage („Beginner Template"): wird NUR auf Knopfdruck eingefügt (source=template),
// jeder Eintrag einzeln editier-/löschbar. Bewusst knapp & atomar gehalten.
const STARTER_TEMPLATE: { content: string; category: string }[] = [
{ content: "Der Nutzer heißt Tobi — Homelab-Entwickler, bevorzugt pragmatische Lösungen.", category: "user" },
{ content: "Bevorzugt knappe, deutsche Antworten.", category: "user" },
{ content: "Deploy nur via PowerShell-Push (Git Credential Manager).", category: "instruction" },
{ content: "Tests bei Deployments nie überspringen.", category: "instruction" },
{ content: "AI-Box: 192.168.178.151 (Ryzen AI MAX+ 395, 122 GB Unified-RAM).", category: "stable" },
{ content: "Agent-Hirn = fast (Qwen3.6-35B-A3B), immer warm.", category: "stable" },
]
export function MemoryView() {
const [filter, setFilter] = useState("")
const [q, setQ] = useState("")
const [content, setContent] = useState("")
const [category, setCategory] = useState("stable")
const [deduping, setDeduping] = useState(false)
const [view, setView] = useState<"liste" | "graph">("liste")
const [seeding, setSeeding] = useState(false)
const [view, setView] = useState<"liste" | "graph">("graph")
const [showAdd, setShowAdd] = useState(false)
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const { data: allItems = [] } = useMemory({})
const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
const { data: graph } = useMemoryGraph(view === "graph")
const error = itemsErr ? String(itemsErr) : ""
@@ -53,14 +64,27 @@ export function MemoryView() {
qc.invalidateQueries({ queryKey: ["memory-graph"] })
}
const stats = useMemo(() => {
const total = allItems.length
const auto = allItems.filter((m) => AUTO_SOURCES.has(m.source)).length
return { total, auto, manual: total - auto, cats: new Set(allItems.map((m) => m.category)).size }
}, [allItems])
const empty = allItems.length === 0
// Graph clientseitig nach Suchtext filtern (Knoten + deren Kanten).
const graphData = useMemo(() => {
const g = graph ?? { nodes: [], edges: [] }
if (!q.trim()) return g
const ql = q.toLowerCase()
const ns = g.nodes.filter((n) => n.content.toLowerCase().includes(ql))
const ids = new Set(ns.map((n) => n.id))
return { nodes: ns, edges: g.edges.filter((e) => ids.has(e.source) && ids.has(e.target)) }
}, [graph, q])
async function add() {
if (!content.trim()) return
await api("/api/memory", {
method: "POST",
body: JSON.stringify({ content, category, source: "ui" })
})
setContent("")
reloadMemory()
await api("/api/memory", { method: "POST", body: JSON.stringify({ content, category, source: "ui" }) })
setContent(""); setShowAdd(false); reloadMemory()
}
async function del(id: string) {
@@ -68,239 +92,232 @@ export function MemoryView() {
reloadMemory()
}
function applyTemplate() {
showConfirm(
"Starter-Vorlage einfügen",
`${STARTER_TEMPLATE.length} Beispiel-Fakten über dein Setup einfügen? Du kannst jeden einzeln bearbeiten oder löschen.`,
async () => {
setSeeding(true)
try {
for (const t of STARTER_TEMPLATE) {
await api("/api/memory", { method: "POST", body: JSON.stringify({ content: t.content, category: t.category, source: "template" }) })
}
reloadMemory()
} catch (e: any) {
showAlert("Fehler", `Vorlage konnte nicht eingefügt werden: ${e.message}`)
} finally {
setSeeding(false)
}
},
)
}
async function cleanup() {
setDeduping(true)
try {
const dry = await api<DedupeResult>("/api/memory/dedupe", {
method: "POST",
body: JSON.stringify({ apply: false }),
})
if (dry.duplicate_count === 0) {
showAlert("Ergebnis", "Keine Dubletten gefunden — alles sauber.")
return
}
showConfirm(
"Deduplizierung bestätigen",
const dry = await api<DedupeResult>("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: false }) })
if (dry.duplicate_count === 0) { showAlert("Ergebnis", "Keine Dubletten gefunden — alles sauber."); return }
showConfirm("Deduplizierung bestätigen",
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
async () => {
try {
await api("/api/memory/dedupe", {
method: "POST",
body: JSON.stringify({ apply: true })
})
reloadMemory()
} catch (e: any) {
showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
}
}
)
try { await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) }); reloadMemory() }
catch (e: any) { showAlert("Fehler", `Fehler beim Löschen: ${e.message}`) }
})
} catch (e: any) {
showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`)
} finally {
setDeduping(false)
}
} finally { setDeduping(false) }
}
const StatChip = ({ value, label, accent }: { value: number; label: string; accent?: "auto" }) => (
<span className={cn("flex items-center gap-1.5 text-[11px] rounded-lg px-2.5 py-1 border",
accent === "auto" ? "text-emerald-400 bg-emerald-500/[0.07] border-emerald-500/20" : "text-muted-foreground bg-card/40 border-border/50")}>
{accent === "auto" && <Sparkles className="h-3 w-3" />}
<b className={cn("font-semibold", accent === "auto" ? "" : "text-foreground")}>{value}</b> {label}
</span>
)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div className="space-y-5">
{/* Kopf / Werkzeugleiste */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Gedächtnis-Pool (Memory)
Gedächtnis-Pool
</h1>
<p className="text-sm text-muted-foreground">
Die geteilte Konstitution des Systems. Alle Instanzen (Hermes, IDEs, Gateway) lesen und schreiben hierauf per MCP-Protokoll.
Geteilte Konstitution Hermes, IDEs &amp; Gateway lesen und lernen hier per MCP.
</p>
</div>
<div className="flex items-center gap-2 shrink-0 self-start">
<div className="flex items-center gap-2 flex-wrap">
<div className="relative">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Semantisch suchen…"
className="w-52 h-9 pl-8 pr-3 rounded-lg border border-border/60 bg-card/45 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<button onClick={() => setShowAdd((s) => !s)}
className="h-9 px-3 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer shadow-md shadow-primary/10">
<Plus className="h-4 w-4" /> Eintrag
</button>
<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 */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Neuen Eintrag anlegen</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Füge eine neue Regel, eine Vorliebe oder einen stabilen Fakt über das Projekt oder dich hinzu..."
rows={3}
className="w-full resize-none rounded-xl border border-border/60 bg-background/30 p-3.5 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground transition-all leading-relaxed"
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Kategorie</span>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="h-8 rounded-lg border border-border/60 bg-background/50 px-2 py-1 text-xs outline-none font-semibold text-foreground cursor-pointer"
>
{CATEGORIES.map((c) => (
<option key={c} value={c} className="bg-popover text-foreground">
{CAT_CONFIG[c]?.label || c}
</option>
))}
</select>
</div>
<button
onClick={add}
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer shadow-md shadow-primary/10"
>
<Plus className="h-4 w-4" /> Speichern
</button>
</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">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Semantisch durchsuchen (nach Bedeutung, nicht nur Stichwort)..."
className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-card/45 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="flex flex-wrap items-center gap-1.5 p-1 bg-card/30 border border-border/40 rounded-xl overflow-x-auto max-w-full">
<button
onClick={() => setFilter("")}
className={cn(
"h-7 px-3 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer whitespace-nowrap",
!filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
Alle
</button>
{CATEGORIES.map((c) => {
const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon
return (
<button
key={c}
onClick={() => setFilter(c)}
className={cn(
"h-7 px-2.5 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1 whitespace-nowrap",
filter === c
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-3 w-3" />
<span>{conf.label}</span>
{([["liste", List, "Liste"], ["graph", Share2, "Graph"]] as const).map(([v, Icon, lbl]) => (
<button key={v} onClick={() => setView(v)}
className={cn("flex h-7 px-2.5 items-center gap-1.5 text-[11px] font-semibold rounded-md transition-all cursor-pointer",
view === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
<Icon className="h-3.5 w-3.5" /> {lbl}
</button>
)
})}
))}
</div>
<button onClick={cleanup} disabled={deduping}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-card hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50"
title="Deduplizieren">
<Sparkles className="h-4 w-4 text-primary" />
</button>
</div>
</div>
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Fehler beim Laden des Gedächtnisses: {error}
{/* Statuszeile */}
{!empty && (
<div className="flex flex-wrap items-center gap-2">
<StatChip value={stats.total} label="Fakten" />
<StatChip value={stats.auto} label="auto gelernt" accent="auto" />
<StatChip value={stats.manual} label="manuell" />
<StatChip value={stats.cats} label="Kategorien" />
<span className="ml-auto text-[11px] text-muted-foreground/70 flex items-center gap-1.5">
<Sparkles className="h-3 w-3 text-emerald-400" /> lernt automatisch aus Hermes-Gesprächen
</span>
</div>
)}
{/* Facts List */}
<div className="space-y-3">
{items.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
Keine Einträge für die aktuellen Filterkriterien gefunden.
{/* Eintrag anlegen (einklappbar) */}
{showAdd && (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Neuen Eintrag anlegen</div>
<button onClick={() => setShowAdd(false)} className="text-muted-foreground hover:text-foreground"><X className="h-4 w-4" /></button>
</div>
) : (
items.map((m) => {
const conf = CAT_CONFIG[m.category] || DEFAULT_CAT
const Icon = conf.icon
return (
<div
key={m.id}
className={cn(
"flex items-start justify-between gap-4 p-4 rounded-xl border border-l-4 bg-card/45 backdrop-blur-md border-border/60 shadow-md shadow-black/5 hover:border-primary/20 transition-all group",
BORDER_CLASSES[m.category] || "border-l-muted"
)}
>
<div className="flex items-start gap-3 flex-1 min-w-0">
<span className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider font-mono shrink-0",
conf.bg, conf.text
)}>
<Icon className="h-3 w-3" />
<span className="hidden sm:inline">{conf.label}</span>
</span>
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1">{m.content}</span>
</div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2}
placeholder="Eine Regel, Vorliebe oder einen stabilen Fakt über dich oder das Projekt…"
className="w-full resize-none rounded-xl border border-border/60 bg-background/30 p-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground leading-relaxed" />
<div className="flex flex-wrap items-center justify-between gap-3">
<select value={category} onChange={(e) => setCategory(e.target.value)}
className="h-8 rounded-lg border border-border/60 bg-background/50 px-2 text-xs outline-none font-semibold text-foreground cursor-pointer">
{CATEGORIES.map((c) => <option key={c} value={c} className="bg-popover text-foreground">{CAT_CONFIG[c]?.label || c}</option>)}
</select>
<button onClick={add} className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer">
<Plus className="h-4 w-4" /> Speichern
</button>
</div>
</div>
)}
<div className="flex items-center gap-3 shrink-0">
{typeof m.score === "number" && (
<span
className="text-[9px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded uppercase tracking-wider"
title="Relevanz der semantischen Suche"
>
{Math.round(m.score * 100)}%
</span>
)}
<span
className={cn(
"flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider",
AUTO_SOURCES.has(m.source)
? "text-emerald-400 bg-emerald-500/10"
: "text-muted-foreground/60 bg-background/20"
)}
title={AUTO_SOURCES.has(m.source) ? "Automatisch gelernt" : "Manuell angelegt"}
>
{AUTO_SOURCES.has(m.source) && <Sparkles className="h-2.5 w-2.5" />}
{m.source}
</span>
<button
onClick={() => del(m.id)}
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100"
title="Eintrag löschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{error && <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">Fehler: {error}</div>}
{/* Inhalt: Empty State · Graph · Liste */}
{empty ? (
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-10 flex flex-col items-center text-center gap-5">
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center">
<Brain className="h-7 w-7 text-primary" />
</div>
<div className="space-y-1.5 max-w-md">
<h3 className="text-base font-semibold text-foreground">Dein Gedächtnis ist noch leer</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Es füllt sich automatisch, sobald Hermes mit dir spricht im Terminal, über Telegram oder aus der IDE.
Oder leg jetzt mit einer Vorlage los:
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<button onClick={applyTemplate} disabled={seeding}
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer disabled:opacity-50">
<Wand2 className="h-4 w-4" /> {seeding ? "Füge ein…" : "Starter-Vorlage einfügen"}
</button>
<button onClick={() => setShowAdd(true)}
className="h-9 px-4 rounded-lg border border-border/60 bg-card text-xs font-semibold hover:border-primary/50 transition-all flex items-center gap-1.5 cursor-pointer">
<Plus className="h-4 w-4" /> Eigener Eintrag
</button>
</div>
<div className="w-full max-w-md text-left">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60 mb-2">Die Vorlage enthält</div>
<div className="flex flex-col gap-1">
{STARTER_TEMPLATE.map((t, i) => {
const conf = CAT_CONFIG[t.category] || DEFAULT_CAT
return (
<div key={i} className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className={cn("text-[8px] font-bold uppercase px-1.5 py-0.5 rounded shrink-0", conf.bg, conf.text)}>{conf.label}</span>
<span className="truncate">{t.content}</span>
</div>
)
})}
</div>
</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={graphData} onDelete={del} />
</Suspense>
</GraphErrorBoundary>
) : (
<>
{/* Kategorie-Filter */}
<div className="flex flex-wrap items-center gap-1.5 p-1 bg-card/30 border border-border/40 rounded-xl w-fit">
<button onClick={() => setFilter("")}
className={cn("h-7 px-3 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer",
!filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>Alle</button>
{CATEGORIES.map((c) => {
const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon
return (
<button key={c} onClick={() => setFilter(c)}
className={cn("h-7 px-2.5 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1",
filter === c ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
<Icon className="h-3 w-3" /> {conf.label}
</button>
)
})}
</div>
<div className="space-y-3">
{items.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
Keine Einträge für die aktuellen Filterkriterien gefunden.
</div>
)
})
)}
</div>
</>
) : items.map((m) => {
const conf = CAT_CONFIG[m.category] || DEFAULT_CAT
const Icon = conf.icon
const isAuto = AUTO_SOURCES.has(m.source)
return (
<div key={m.id} className={cn("flex items-start justify-between gap-4 p-4 rounded-xl border border-l-4 bg-card/45 backdrop-blur-md border-border/60 shadow-md shadow-black/5 hover:border-primary/20 transition-all group",
BORDER_CLASSES[m.category] || "border-l-muted")}>
<div className="flex items-start gap-3 flex-1 min-w-0">
<span className={cn("flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider font-mono shrink-0", conf.bg, conf.text)}>
<Icon className="h-3 w-3" /><span className="hidden sm:inline">{conf.label}</span>
</span>
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1">{m.content}</span>
</div>
<div className="flex items-center gap-3 shrink-0">
{typeof m.score === "number" && (
<span className="text-[9px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded uppercase tracking-wider" title="Relevanz der semantischen Suche">{Math.round(m.score * 100)}%</span>
)}
<span className={cn("flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider",
isAuto ? "text-emerald-400 bg-emerald-500/10" : "text-muted-foreground/60 bg-background/20")}
title={isAuto ? "Automatisch gelernt" : "Manuell angelegt"}>
{isAuto && <Sparkles className="h-2.5 w-2.5" />}{m.source}
</span>
<button onClick={() => del(m.id)} title="Eintrag löschen"
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})}
</div>
</>
)}
{dialogElement}