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 * @license
* Copyright 2010-2023 Three.js Authors * Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT * 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="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-DuvhfsJu.js"></script> <script type="module" crossorigin src="/assets/index-D7EBqqTV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEncQp-z.css"> <link rel="stylesheet" crossorigin href="/assets/index-DNH8F21o.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+248 -231
View File
@@ -1,49 +1,60 @@
import { useState, lazy, Suspense } from "react" import { useState, useMemo, lazy, Suspense } from "react"
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen, Share2, List } from "lucide-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 { api, type DedupeResult } from "@/lib/api"
import { useMemory, useMemoryGraph, useQueryClient } from "@/lib/queries" import { useMemory, useMemoryGraph, useQueryClient } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { GraphErrorBoundary } from "./GraphErrorBoundary" import { GraphErrorBoundary } from "./GraphErrorBoundary"
// reagraph + three.js sind schwer → erst laden, wenn der Graph-Tab geöffnet wird. // 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 GraphView = lazy(() => import("./GraphView").then((m) => ({ default: m.GraphView })))
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"] const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
// Herkunft: automatisch gelernt (Mem0-Extraktion / Agent) vs. manuell angelegt. // Herkunft: automatisch gelernt (Mem0-Extraktion / Agent / Hermes) vs. manuell/Vorlage.
const AUTO_SOURCES = new Set(["auto", "agent"]) 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 }> = { const CAT_CONFIG: Record<string, { label: string; icon: any; 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" }, user: { label: "User", icon: User, 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" }, instruction: { label: "Regel", icon: Scroll, 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" }, stable: { label: "Fakt", icon: Shield, 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" }, versioned: { label: "Version", icon: Tag, 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" }, ephemeral: { label: "Temporär", icon: Clock, bg: "bg-pink-500/10", text: "text-pink-400" },
} }
const DEFAULT_CAT = { label: "Gedächtnis", icon: BookOpen, bg: "bg-muted/10", text: "text-muted-foreground" }
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 BORDER_CLASSES: Record<string, string> = { const BORDER_CLASSES: Record<string, string> = {
user: "border-l-cyan-500/80", user: "border-l-cyan-500/80", instruction: "border-l-violet-500/80", stable: "border-l-indigo-500/80",
instruction: "border-l-violet-500/80", versioned: "border-l-amber-500/80", ephemeral: "border-l-pink-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() { export function MemoryView() {
const [filter, setFilter] = useState("") const [filter, setFilter] = useState("")
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [content, setContent] = useState("") const [content, setContent] = useState("")
const [category, setCategory] = useState("stable") const [category, setCategory] = useState("stable")
const [deduping, setDeduping] = useState(false) 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 qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog() const { showAlert, showConfirm, dialogElement } = useDialog()
const { data: allItems = [] } = useMemory({})
const { data: items = [], error: itemsErr } = useMemory({ q, category: filter }) const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
const { data: graph } = useMemoryGraph(view === "graph") const { data: graph } = useMemoryGraph(view === "graph")
const error = itemsErr ? String(itemsErr) : "" const error = itemsErr ? String(itemsErr) : ""
@@ -53,14 +64,27 @@ export function MemoryView() {
qc.invalidateQueries({ queryKey: ["memory-graph"] }) 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() { async function add() {
if (!content.trim()) return if (!content.trim()) return
await api("/api/memory", { await api("/api/memory", { method: "POST", body: JSON.stringify({ content, category, source: "ui" }) })
method: "POST", setContent(""); setShowAdd(false); reloadMemory()
body: JSON.stringify({ content, category, source: "ui" })
})
setContent("")
reloadMemory()
} }
async function del(id: string) { async function del(id: string) {
@@ -68,239 +92,232 @@ export function MemoryView() {
reloadMemory() 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() { async function cleanup() {
setDeduping(true) setDeduping(true)
try { try {
const dry = await api<DedupeResult>("/api/memory/dedupe", { const dry = await api<DedupeResult>("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: false }) })
method: "POST", if (dry.duplicate_count === 0) { showAlert("Ergebnis", "Keine Dubletten gefunden — alles sauber."); return }
body: JSON.stringify({ apply: false }), showConfirm("Deduplizierung bestätigen",
})
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?`, `${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
async () => { async () => {
try { try { await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) }); reloadMemory() }
await api("/api/memory/dedupe", { catch (e: any) { showAlert("Fehler", `Fehler beim Löschen: ${e.message}`) }
method: "POST", })
body: JSON.stringify({ apply: true })
})
reloadMemory()
} catch (e: any) {
showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
}
}
)
} catch (e: any) { } catch (e: any) {
showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`) showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`)
} finally { } finally { setDeduping(false) }
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 ( return (
<div className="space-y-6"> <div className="space-y-5">
{/* Header */} {/* Kopf / Werkzeugleiste */}
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
<div> <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"> <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> </h1>
<p className="text-sm text-muted-foreground"> <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> </p>
</div> </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"> <div className="flex items-center gap-1 p-1 bg-card/30 border border-border/40 rounded-lg">
<button {([["liste", List, "Liste"], ["graph", Share2, "Graph"]] as const).map(([v, Icon, lbl]) => (
onClick={() => setView("liste")} <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", 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")} view === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
> <Icon className="h-3.5 w-3.5" /> {lbl}
<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>
</button> </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>
</div> </div>
{error && ( {/* Statuszeile */}
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400"> {!empty && (
Fehler beim Laden des Gedächtnisses: {error} <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> </div>
)} )}
{/* Facts List */} {/* Eintrag anlegen (einklappbar) */}
<div className="space-y-3"> {showAdd && (
{items.length === 0 ? ( <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 space-y-3">
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center"> <div className="flex items-center justify-between">
Keine Einträge für die aktuellen Filterkriterien gefunden. <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> </div>
) : ( <textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2}
items.map((m) => { placeholder="Eine Regel, Vorliebe oder einen stabilen Fakt über dich oder das Projekt…"
const conf = CAT_CONFIG[m.category] || DEFAULT_CAT 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" />
const Icon = conf.icon <div className="flex flex-wrap items-center justify-between gap-3">
return ( <select value={category} onChange={(e) => setCategory(e.target.value)}
<div className="h-8 rounded-lg border border-border/60 bg-background/50 px-2 text-xs outline-none font-semibold text-foreground cursor-pointer">
key={m.id} {CATEGORIES.map((c) => <option key={c} value={c} className="bg-popover text-foreground">{CAT_CONFIG[c]?.label || c}</option>)}
className={cn( </select>
"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", <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">
BORDER_CLASSES[m.category] || "border-l-muted" <Plus className="h-4 w-4" /> Speichern
)} </button>
> </div>
<div className="flex items-start gap-3 flex-1 min-w-0"> </div>
<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"> {error && <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">Fehler: {error}</div>}
{typeof m.score === "number" && (
<span {/* Inhalt: Empty State · Graph · Liste */}
className="text-[9px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded uppercase tracking-wider" {empty ? (
title="Relevanz der semantischen Suche" <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">
{Math.round(m.score * 100)}% <Brain className="h-7 w-7 text-primary" />
</span> </div>
)} <div className="space-y-1.5 max-w-md">
<span <h3 className="text-base font-semibold text-foreground">Dein Gedächtnis ist noch leer</h3>
className={cn( <p className="text-xs text-muted-foreground leading-relaxed">
"flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider", Es füllt sich automatisch, sobald Hermes mit dir spricht im Terminal, über Telegram oder aus der IDE.
AUTO_SOURCES.has(m.source) Oder leg jetzt mit einer Vorlage los:
? "text-emerald-400 bg-emerald-500/10" </p>
: "text-muted-foreground/60 bg-background/20" </div>
)} <div className="flex flex-wrap items-center justify-center gap-2">
title={AUTO_SOURCES.has(m.source) ? "Automatisch gelernt" : "Manuell angelegt"} <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">
{AUTO_SOURCES.has(m.source) && <Sparkles className="h-2.5 w-2.5" />} <Wand2 className="h-4 w-4" /> {seeding ? "Füge ein…" : "Starter-Vorlage einfügen"}
{m.source} </button>
</span> <button onClick={() => setShowAdd(true)}
<button 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">
onClick={() => del(m.id)} <Plus className="h-4 w-4" /> Eigener Eintrag
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" </button>
title="Eintrag löschen" </div>
> <div className="w-full max-w-md text-left">
<Trash2 className="h-3.5 w-3.5" /> <div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60 mb-2">Die Vorlage enthält</div>
</button> <div className="flex flex-col gap-1">
</div> {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
</div> 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} {dialogElement}