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>
+217 -200
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,237 +92,230 @@ 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="flex items-center gap-1 p-1 bg-card/30 border border-border/40 rounded-lg"> <div className="relative">
<button <input
onClick={() => setView("liste")} value={q}
className={cn("flex h-7 px-2.5 items-center gap-1.5 text-[11px] font-semibold rounded-md transition-all cursor-pointer", onChange={(e) => setQ(e.target.value)}
view === "liste" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")} 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"
<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"
/> />
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<div className="flex flex-wrap items-center justify-between gap-3"> </div>
<div className="flex items-center gap-2"> <button onClick={() => setShowAdd((s) => !s)}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Kategorie</span> 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">
<select <Plus className="h-4 w-4" /> Eintrag
value={category} </button>
onChange={(e) => setCategory(e.target.value)} <div className="flex items-center gap-1 p-1 bg-card/30 border border-border/40 rounded-lg">
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" {([["liste", List, "Liste"], ["graph", Share2, "Graph"]] as const).map(([v, Icon, lbl]) => (
> <button key={v} onClick={() => setView(v)}
{CATEGORIES.map((c) => ( className={cn("flex h-7 px-2.5 items-center gap-1.5 text-[11px] font-semibold rounded-md transition-all cursor-pointer",
<option key={c} value={c} className="bg-popover text-foreground"> view === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
{CAT_CONFIG[c]?.label || c} <Icon className="h-3.5 w-3.5" /> {lbl}
</option> </button>
))} ))}
</select> </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>
<button {/* Statuszeile */}
onClick={add} {!empty && (
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" <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>
)}
{/* 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>
<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 <Plus className="h-4 w-4" /> Speichern
</button> </button>
</div> </div>
</div> </div>
)}
{view === "graph" ? ( {error && <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">Fehler: {error}</div>}
<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>}> {/* Inhalt: Empty State · Graph · Liste */}
<GraphView data={graph ?? { nodes: [], edges: [] }} onDelete={del} /> {empty ? (
</Suspense> <div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-10 flex flex-col items-center text-center gap-5">
</GraphErrorBoundary> <div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center">
) : ( <Brain className="h-7 w-7 text-primary" />
<>
{/* 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>
<div className="space-y-1.5 max-w-md">
<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"> <h3 className="text-base font-semibold text-foreground">Dein Gedächtnis ist noch leer</h3>
<button <p className="text-xs text-muted-foreground leading-relaxed">
onClick={() => setFilter("")} Es füllt sich automatisch, sobald Hermes mit dir spricht im Terminal, über Telegram oder aus der IDE.
className={cn( Oder leg jetzt mit einer Vorlage los:
"h-7 px-3 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer whitespace-nowrap", </p>
!filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground" </div>
)} <div className="flex flex-wrap items-center justify-center gap-2">
> <button onClick={applyTemplate} disabled={seeding}
Alle 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>
{CATEGORIES.map((c) => { <button onClick={() => setShowAdd(true)}
const conf = CAT_CONFIG[c] || DEFAULT_CAT 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">
const Icon = conf.icon <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 ( return (
<button <div key={i} className="flex items-center gap-2 text-[11px] text-muted-foreground">
key={c} <span className={cn("text-[8px] font-bold uppercase px-1.5 py-0.5 rounded shrink-0", conf.bg, conf.text)}>{conf.label}</span>
onClick={() => setFilter(c)} <span className="truncate">{t.content}</span>
className={cn( </div>
"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>
) )
})} })}
</div> </div>
</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}
</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>
{/* Facts List */}
<div className="space-y-3"> <div className="space-y-3">
{items.length === 0 ? ( {items.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center"> <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. Keine Einträge für die aktuellen Filterkriterien gefunden.
</div> </div>
) : ( ) : items.map((m) => {
items.map((m) => {
const conf = CAT_CONFIG[m.category] || DEFAULT_CAT const conf = CAT_CONFIG[m.category] || DEFAULT_CAT
const Icon = conf.icon const Icon = conf.icon
const isAuto = AUTO_SOURCES.has(m.source)
return ( return (
<div <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",
key={m.id} BORDER_CLASSES[m.category] || "border-l-muted")}>
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"> <div className="flex items-start gap-3 flex-1 min-w-0">
<span className={cn( <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)}>
"flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider font-mono shrink-0", <Icon className="h-3 w-3" /><span className="hidden sm:inline">{conf.label}</span>
conf.bg, conf.text
)}>
<Icon className="h-3 w-3" />
<span className="hidden sm:inline">{conf.label}</span>
</span> </span>
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1">{m.content}</span> <span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1">{m.content}</span>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
{typeof m.score === "number" && ( {typeof m.score === "number" && (
<span <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>
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 <span className={cn("flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider",
className={cn( isAuto ? "text-emerald-400 bg-emerald-500/10" : "text-muted-foreground/60 bg-background/20")}
"flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider", title={isAuto ? "Automatisch gelernt" : "Manuell angelegt"}>
AUTO_SOURCES.has(m.source) {isAuto && <Sparkles className="h-2.5 w-2.5" />}{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> </span>
<button <button onClick={() => del(m.id)} title="Eintrag löschen"
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">
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" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
) )
}) })}
)}
</div> </div>
</> </>
)} )}