Politur: Verschmelzung von Liste, Graph und Details zu einem einheitlichen, schwebenden Glassmorphism-Dashboard (Graph im Hintergrund, Panels schweben elegant davor)
This commit is contained in:
-44
File diff suppressed because one or more lines are too long
+44
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-765
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+765
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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-Ch3qlJmN.js"></script>
|
<script type="module" crossorigin src="/assets/index-P6XXlxiQ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-1USExdfg.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CunuYLZu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+123
-200
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import ForceGraph2D from "react-force-graph-2d"
|
import ForceGraph2D from "react-force-graph-2d"
|
||||||
import { forceCollide, forceX, forceY } from "d3-force"
|
import { forceCollide, forceX, forceY } from "d3-force"
|
||||||
import { Trash2, Sparkles, Share2, RefreshCw } from "lucide-react"
|
import { RefreshCw } from "lucide-react"
|
||||||
import { type MemoryGraph } from "@/lib/api"
|
import { type MemoryGraph } from "@/lib/api"
|
||||||
|
|
||||||
const CAT_COLOR: Record<string, string> = {
|
const CAT_COLOR: Record<string, string> = {
|
||||||
@@ -10,7 +10,6 @@ const CAT_COLOR: Record<string, string> = {
|
|||||||
const CAT_LABEL: Record<string, string> = {
|
const CAT_LABEL: Record<string, string> = {
|
||||||
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
|
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
|
||||||
}
|
}
|
||||||
const AUTO = new Set(["auto", "agent", "hermes"])
|
|
||||||
const EDGE_HI = "rgba(99, 102, 241, 0.9)"
|
const EDGE_HI = "rgba(99, 102, 241, 0.9)"
|
||||||
const DIM_EDGE = "rgba(255, 255, 255, 0.02)"
|
const DIM_EDGE = "rgba(255, 255, 255, 0.02)"
|
||||||
|
|
||||||
@@ -22,8 +21,11 @@ function hexToRgba(hex: string, alpha: number): string {
|
|||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) {
|
export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
data: MemoryGraph
|
||||||
|
selectedNodeId: string | null
|
||||||
|
onNodeSelect: (id: string | null) => void
|
||||||
|
}) {
|
||||||
const [hovered, setHovered] = useState<string | null>(null)
|
const [hovered, setHovered] = useState<string | null>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const fgRef = useRef<any>(null)
|
const fgRef = useRef<any>(null)
|
||||||
@@ -114,7 +116,7 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
|
|||||||
fg.d3ReheatSimulation()
|
fg.d3ReheatSimulation()
|
||||||
}, [graphData])
|
}, [graphData])
|
||||||
|
|
||||||
const activeNode = hovered || selected
|
const activeNode = hovered || selectedNodeId
|
||||||
|
|
||||||
// Layout zurücksetzen/neu anschubsen
|
// Layout zurücksetzen/neu anschubsen
|
||||||
const relayout = () => {
|
const relayout = () => {
|
||||||
@@ -122,19 +124,6 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
|
|||||||
fgRef.current.zoomToFit(400, 40)
|
fgRef.current.zoomToFit(400, 40)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
|
|
||||||
|
|
||||||
// Nachbarn über das statische, sichere Props-Array ermitteln
|
|
||||||
const neighbors = useMemo(() => {
|
|
||||||
if (!selected) return []
|
|
||||||
const ids = new Set<string>()
|
|
||||||
data.edges.forEach((e) => {
|
|
||||||
if (e.source === selected) ids.add(e.target)
|
|
||||||
if (e.target === selected) ids.add(e.source)
|
|
||||||
})
|
|
||||||
return data.nodes.filter((n) => ids.has(n.id))
|
|
||||||
}, [selected, data])
|
|
||||||
|
|
||||||
// Nachbarmenge für extrem schnellen Lookup im Render-Loop
|
// Nachbarmenge für extrem schnellen Lookup im Render-Loop
|
||||||
const connectedNodes = useMemo(() => {
|
const connectedNodes = useMemo(() => {
|
||||||
if (!activeNode) return new Set<string>()
|
if (!activeNode) return new Set<string>()
|
||||||
@@ -146,207 +135,141 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
|
|||||||
return set
|
return set
|
||||||
}, [activeNode, data])
|
}, [activeNode, data])
|
||||||
|
|
||||||
if (!data.nodes.length) {
|
|
||||||
return (
|
|
||||||
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
|
|
||||||
Noch keine Fakten — der Graph füllt sich, sobald Hermes lernt oder du Einträge anlegst.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-3">
|
<div className="absolute inset-0" ref={containerRef}
|
||||||
<div className="relative h-[calc(100vh-13rem)] min-h-[560px] rounded-2xl border border-border/60 overflow-hidden"
|
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
|
||||||
ref={containerRef}
|
|
||||||
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
|
|
||||||
|
|
||||||
<ForceGraph2D
|
<ForceGraph2D
|
||||||
ref={fgRef}
|
ref={fgRef}
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
height={dimensions.height}
|
height={dimensions.height}
|
||||||
graphData={graphData}
|
graphData={graphData}
|
||||||
backgroundColor="rgba(0,0,0,0)"
|
backgroundColor="rgba(0,0,0,0)"
|
||||||
nodeRelSize={4}
|
nodeRelSize={4}
|
||||||
cooldownTicks={120}
|
cooldownTicks={120}
|
||||||
|
|
||||||
// Hover- und Klick-Logik
|
// Hover- und Klick-Logik
|
||||||
onNodeClick={(node: any) => setSelected(node.id)}
|
onNodeClick={(node: any) => onNodeSelect(node.id)}
|
||||||
onBackgroundClick={() => setSelected(null)}
|
onBackgroundClick={() => onNodeSelect(null)}
|
||||||
onNodeHover={(node: any) => {
|
onNodeHover={(node: any) => {
|
||||||
setHovered(node ? node.id : null)
|
setHovered(node ? node.id : null)
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
containerRef.current.style.cursor = node ? "pointer" : "default"
|
containerRef.current.style.cursor = node ? "pointer" : "default"
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Kanten-Animationen & Partikelfluss
|
// Kanten-Animationen & Partikelfluss
|
||||||
linkColor={(link: any) => {
|
linkColor={(link: any) => {
|
||||||
const sId = typeof link.source === "object" ? link.source.id : link.source
|
const sId = typeof link.source === "object" ? link.source.id : link.source
|
||||||
const tId = typeof link.target === "object" ? link.target.id : link.target
|
const tId = typeof link.target === "object" ? link.target.id : link.target
|
||||||
|
|
||||||
// Highlight-Kanten leuchten in Indigo auf
|
if (activeNode === sId || activeNode === tId) return EDGE_HI
|
||||||
if (activeNode === sId || activeNode === tId) return EDGE_HI
|
if (activeNode) return DIM_EDGE
|
||||||
|
|
||||||
// Wenn ein anderer Knoten aktiv ist, blenden wir diese Kante fast komplett aus
|
const sourceNode = graphData.nodes.find(n => n.id === sId)
|
||||||
if (activeNode) return DIM_EDGE
|
const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b"
|
||||||
|
return hexToRgba(catColor, 0.25)
|
||||||
|
}}
|
||||||
|
linkWidth={(link: any) => {
|
||||||
|
const sId = typeof link.source === "object" ? link.source.id : link.source
|
||||||
|
const tId = typeof link.target === "object" ? link.target.id : link.target
|
||||||
|
return (activeNode === sId || activeNode === tId) ? 2.5 : 1.4
|
||||||
|
}}
|
||||||
|
|
||||||
// Standard-Zustand: Kante leuchtet in der Farbe des Quell-Clusters (deutlich sichtbar mit alpha=0.25)
|
// Daten-Partikel fließen auf den aktiven Verbindungen
|
||||||
const sourceNode = graphData.nodes.find(n => n.id === sId)
|
linkDirectionalParticles={(link: any) => {
|
||||||
const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b"
|
const sId = typeof link.source === "object" ? link.source.id : link.source
|
||||||
return hexToRgba(catColor, 0.25)
|
const tId = typeof link.target === "object" ? link.target.id : link.target
|
||||||
}}
|
return (activeNode === sId || activeNode === tId) ? 4 : 0
|
||||||
linkWidth={(link: any) => {
|
}}
|
||||||
const sId = typeof link.source === "object" ? link.source.id : link.source
|
linkDirectionalParticleWidth={2.2}
|
||||||
const tId = typeof link.target === "object" ? link.target.id : link.target
|
linkDirectionalParticleSpeed={0.006}
|
||||||
return (activeNode === sId || activeNode === tId) ? 2.5 : 1.4
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Daten-Partikel fließen auf den aktiven Verbindungen
|
// Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen
|
||||||
linkDirectionalParticles={(link: any) => {
|
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||||
const sId = typeof link.source === "object" ? link.source.id : link.source
|
const x = node.x ?? 0
|
||||||
const tId = typeof link.target === "object" ? link.target.id : link.target
|
const y = node.y ?? 0
|
||||||
return (activeNode === sId || activeNode === tId) ? 4 : 0
|
const size = 5.5 + Math.min(degree[node.id] || 0, 12) * 0.9
|
||||||
}}
|
const color = CAT_COLOR[node.category] || "#64748b"
|
||||||
linkDirectionalParticleWidth={2.2}
|
const isHovered = node.id === hovered
|
||||||
linkDirectionalParticleSpeed={0.006}
|
const isSelected = node.id === selectedNodeId
|
||||||
|
|
||||||
// Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen
|
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
|
||||||
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
const finalColor = isDimmed ? "#1e293b" : color
|
||||||
const x = node.x ?? 0
|
|
||||||
const y = node.y ?? 0
|
|
||||||
const size = 5.5 + Math.min(degree[node.id] || 0, 12) * 0.9
|
|
||||||
const color = CAT_COLOR[node.category] || "#64748b"
|
|
||||||
const isHovered = node.id === hovered
|
|
||||||
const isSelected = node.id === selected
|
|
||||||
|
|
||||||
// Abgedunkelter Zustand, wenn ein anderer Knoten aktiv ist und dieser kein Nachbar ist
|
if (!isDimmed) {
|
||||||
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
|
ctx.shadowColor = color
|
||||||
const finalColor = isDimmed ? "#1e293b" : color
|
ctx.shadowBlur = isHovered || isSelected ? 12 : 6
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Zeichne weichen Glow-Schatten
|
ctx.beginPath()
|
||||||
if (!isDimmed) {
|
ctx.arc(x, y, size, 0, 2 * Math.PI, false)
|
||||||
ctx.shadowColor = color
|
ctx.fillStyle = finalColor
|
||||||
ctx.shadowBlur = isHovered || isSelected ? 12 : 6
|
ctx.fill()
|
||||||
}
|
ctx.shadowBlur = 0
|
||||||
|
|
||||||
// 2. Zeichne den Knoten-Kreis
|
if (isHovered || isSelected) {
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(x, y, size, 0, 2 * Math.PI, false)
|
ctx.arc(x, y, size + 2, 0, 2 * Math.PI, false)
|
||||||
ctx.fillStyle = finalColor
|
ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)"
|
||||||
ctx.fill()
|
ctx.lineWidth = 1.5
|
||||||
ctx.shadowBlur = 0 // Schatten zurücksetzen
|
|
||||||
|
|
||||||
// 3. Zeichne weißen Außenring bei Interaktivität
|
|
||||||
if (isHovered || isSelected) {
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(x, y, size + 2, 0, 2 * Math.PI, false)
|
|
||||||
ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)"
|
|
||||||
ctx.lineWidth = 1.5
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Typografie (Immer scharf & mitskalierend gezeichnet)
|
|
||||||
const label = node.content.length > 30 ? node.content.slice(0, 29) + "…" : node.content
|
|
||||||
const baseFontSize = isHovered || isSelected ? 10 : 9
|
|
||||||
const fontSize = baseFontSize / Math.max(0.6, globalScale) // Verhindert Riesen-Schriften beim Rauszoomen
|
|
||||||
|
|
||||||
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
|
|
||||||
const textWidth = ctx.measureText(label).width
|
|
||||||
const bpad = 4 / Math.max(0.6, globalScale)
|
|
||||||
|
|
||||||
// Render Label-Hintergrund für absolute Lesbarkeit
|
|
||||||
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)"
|
|
||||||
ctx.beginPath()
|
|
||||||
|
|
||||||
// Zeichne abgerundete Pille unter dem Knoten
|
|
||||||
const rx = x - textWidth / 2 - bpad
|
|
||||||
const ry = y + size + (5 / Math.max(0.6, globalScale))
|
|
||||||
const rw = textWidth + bpad * 2
|
|
||||||
const rh = fontSize + bpad * 1.5
|
|
||||||
const rad = 4 / Math.max(0.6, globalScale)
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(rx + rad, ry)
|
|
||||||
ctx.lineTo(rx + rw - rad, ry)
|
|
||||||
ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + rad)
|
|
||||||
ctx.lineTo(rx + rw, ry + rh - rad)
|
|
||||||
ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - rad, ry + rh)
|
|
||||||
ctx.lineTo(rx + rad, ry + rh)
|
|
||||||
ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - rad)
|
|
||||||
ctx.lineTo(rx, ry + rad)
|
|
||||||
ctx.quadraticCurveTo(rx, ry, rx + rad, ry)
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Feine Kontur für die Text-Pille
|
|
||||||
ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.05)"
|
|
||||||
ctx.lineWidth = 0.5 / Math.max(0.6, globalScale)
|
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
// Text zeichnen
|
const label = node.content.length > 30 ? node.content.slice(0, 29) + "…" : node.content
|
||||||
ctx.textAlign = "center"
|
const baseFontSize = isHovered || isSelected ? 10 : 9
|
||||||
ctx.textBaseline = "top"
|
const fontSize = baseFontSize / Math.max(0.6, globalScale)
|
||||||
ctx.fillStyle = isDimmed
|
|
||||||
? "#475569"
|
|
||||||
: (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
|
|
||||||
|
|
||||||
ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale)))
|
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
|
||||||
}}
|
const textWidth = ctx.measureText(label).width
|
||||||
/>
|
const bpad = 4 / Math.max(0.6, globalScale)
|
||||||
|
|
||||||
<button onClick={relayout} title="Knoten neu anordnen"
|
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)"
|
||||||
className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors cursor-pointer">
|
ctx.beginPath()
|
||||||
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Legende */}
|
const rx = x - textWidth / 2 - bpad
|
||||||
<div className="absolute left-3 bottom-3 flex flex-wrap gap-2 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm">
|
const ry = y + size + (5 / Math.max(0.6, globalScale))
|
||||||
{Object.entries(CAT_LABEL).map(([k, l]) => (
|
const rw = textWidth + bpad * 2
|
||||||
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
const rh = fontSize + bpad * 1.5
|
||||||
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
|
const rad = 4 / Math.max(0.6, globalScale)
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 h-[calc(100vh-13rem)] min-h-[560px] overflow-y-auto scrollbar-thin">
|
ctx.beginPath()
|
||||||
{sel ? (
|
ctx.moveTo(rx + rad, ry)
|
||||||
<>
|
ctx.lineTo(rx + rw - rad, ry)
|
||||||
<div className="flex items-center gap-2 mb-3">
|
ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + rad)
|
||||||
<span className="w-3 h-3 rounded-full" style={{ background: CAT_COLOR[sel.category], boxShadow: `0 0 0 2px ${AUTO.has(sel.source) ? "#34d399" : "#475569"}` }} />
|
ctx.lineTo(rx + rw, ry + rh - rad)
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: CAT_COLOR[sel.category] }}>
|
ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - rad, ry + rh)
|
||||||
{CAT_LABEL[sel.category] || sel.category}
|
ctx.lineTo(rx + rad, ry + rh)
|
||||||
</span>
|
ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - rad)
|
||||||
<span className={`ml-auto text-[10px] font-mono flex items-center gap-1 ${AUTO.has(sel.source) ? "text-emerald-400" : "text-muted-foreground/70"}`}>
|
ctx.lineTo(rx, ry + rad)
|
||||||
{AUTO.has(sel.source) && <Sparkles className="h-2.5 w-2.5" />}{sel.source}
|
ctx.quadraticCurveTo(rx, ry, rx + rad, ry)
|
||||||
</span>
|
ctx.closePath()
|
||||||
</div>
|
ctx.fill()
|
||||||
<div className="text-sm text-foreground leading-relaxed mb-4 break-words">{sel.content}</div>
|
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 mb-2 flex items-center gap-1">
|
ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.05)"
|
||||||
<Share2 className="h-3 w-3" /> verwandte Fakten
|
ctx.lineWidth = 0.5 / Math.max(0.6, globalScale)
|
||||||
</div>
|
ctx.stroke()
|
||||||
<div className="flex flex-col gap-1.5 mb-4">
|
|
||||||
{neighbors.length ? neighbors.map((n) => (
|
ctx.textAlign = "center"
|
||||||
<button key={n.id} onClick={() => setSelected(n.id)}
|
ctx.textBaseline = "top"
|
||||||
className="flex items-center gap-2 text-[11px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer">
|
ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
|
||||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR[n.category] }} />
|
|
||||||
<span className="truncate">{n.content}</span>
|
ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale)))
|
||||||
</button>
|
}}
|
||||||
)) : <span className="text-[11px] text-muted-foreground/60">—</span>}
|
/>
|
||||||
</div>
|
|
||||||
<button onClick={() => { onDelete(sel.id); setSelected(null) }}
|
<button onClick={relayout} title="Zentrieren"
|
||||||
className="flex items-center gap-1.5 text-[11px] text-red-400 border border-red-500/20 rounded-lg px-2.5 py-1.5 hover:bg-red-500/5 transition-all cursor-pointer">
|
className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors cursor-pointer">
|
||||||
<Trash2 className="h-3.5 w-3.5" /> vergessen
|
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
) : (
|
{/* Legende */}
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center text-muted-foreground/70 gap-2 pt-24">
|
<div className="absolute left-3 bottom-3 flex flex-wrap gap-2 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm">
|
||||||
<Share2 className="h-6 w-6" />
|
{Object.entries(CAT_LABEL).map(([k, l]) => (
|
||||||
<div className="text-xs max-w-[180px] leading-relaxed">
|
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
Auf einen Knoten klicken, um den Fakt und seine semantischen Nachbarn zu sehen. Hover hebt das Netz hervor.
|
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+222
-194
@@ -31,15 +31,13 @@ const BORDER_CLASSES: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Onboarding-Prompt: der Nutzer startet damit ein Gespräch mit Hermes (Terminal/Telegram).
|
// Onboarding-Prompt: der Nutzer startet damit ein Gespräch mit Hermes (Terminal/Telegram).
|
||||||
// Hermes interviewt ihn Schritt für Schritt — der Auto-Lern-Hook füllt dabei das Gedächtnis.
|
|
||||||
// Bewusst GENERISCH (nicht auf eine Person zugeschnitten).
|
|
||||||
const ONBOARDING_PROMPT = `Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu:
|
const ONBOARDING_PROMPT = `Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu:
|
||||||
1) wer ich bin und woran ich gerade arbeite,
|
1) wer ich bin und woran ich gerade arbeite,
|
||||||
2) wie ich angesprochen werden möchte,
|
2) wie ich angesprochen werden möchte,
|
||||||
3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
|
3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
|
||||||
4) wichtige Regeln/Konventionen, die du beachten sollst,
|
4) wichtige Regeln/Konventionen, die du beachten sollst,
|
||||||
5) meine Infrastruktur (Server, Dienste – ohne Geheimnisse).
|
5) meine Infrastruktur (Server, Dienste – ohne Geheimnisse).
|
||||||
Frag immer nur eine Sache auf einmal und fass am Ende zusammen, was du dir gemerkt hast.`
|
Frag immer nur eine sache auf einmal und fass am Ende zusammen, was du dir gemerkt hast.`
|
||||||
|
|
||||||
export function MemoryView() {
|
export function MemoryView() {
|
||||||
const [filter, setFilter] = useState("")
|
const [filter, setFilter] = useState("")
|
||||||
@@ -50,6 +48,7 @@ export function MemoryView() {
|
|||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [view, setView] = useState<"liste" | "graph">("liste")
|
const [view, setView] = useState<"liste" | "graph">("liste")
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||||
@@ -134,203 +133,222 @@ export function MemoryView() {
|
|||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`)
|
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" }) => (
|
const sel = useMemo(() => items.find((m) => m.id === selected), [items, selected])
|
||||||
<span className={cn("flex items-center gap-1.5 text-[11px] rounded-lg px-2.5 py-1 border",
|
const neighbors = useMemo(() => {
|
||||||
accent === "auto" ? "text-emerald-400 bg-emerald-500/[0.07] border-emerald-500/20" : "text-muted-foreground bg-card/40 border-border/50")}>
|
if (!sel || !graph) return []
|
||||||
{accent === "auto" && <Sparkles className="h-3 w-3" />}
|
const connectedIds = new Set([
|
||||||
<b className={cn("font-semibold", accent === "auto" ? "" : "text-foreground")}>{value}</b> {label}
|
...graph.edges.filter((e) => e.source === sel.id).map((e) => e.target),
|
||||||
</span>
|
...graph.edges.filter((e) => e.target === sel.id).map((e) => e.source)
|
||||||
)
|
])
|
||||||
|
return items.filter((m) => connectedIds.has(m.id))
|
||||||
|
}, [sel, graph, items])
|
||||||
|
|
||||||
|
const CAT_COLOR_MAP: Record<string, string> = {
|
||||||
|
identity: "#00f5ff", knowledge: "#3b82f6", rules: "#d946ef", events: "#fbbf24",
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAT_LABEL_MAP: Record<string, string> = {
|
||||||
|
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO = new Set(["auto", "agent", "hermes"])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Zweispaltiges Haupt-Layout ab Y=0 */}
|
{empty ? (
|
||||||
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 sm: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">
|
||||||
{/* Linke Spalte (Titel, Statistiken, Filter, Liste) */}
|
<MessagesSquare className="h-7 w-7 text-primary" />
|
||||||
<div className="w-full lg:max-w-md xl:max-w-xl lg:shrink-0 space-y-5">
|
</div>
|
||||||
{/* Titel & Beschreibung */}
|
<div className="space-y-1.5 max-w-lg">
|
||||||
<div>
|
<h3 className="text-base font-semibold text-foreground">Lass Hermes dich kennenlernen</h3>
|
||||||
<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">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
Gedächtnis-Pool
|
Statt Fakten einzutippen: führ ein kurzes Onboarding-Gespräch mit Hermes. Was ihr besprecht,
|
||||||
</h1>
|
merkt sich das Gedächtnis automatisch — du musst nichts manuell pflegen.
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Geteilte Konstitution — Hermes, IDEs & Gateway lesen und lernen hier per MCP.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistiken (Chips) */}
|
{/* Vorgeschlagener Prompt */}
|
||||||
{!empty && (
|
<div className="w-full max-w-lg text-left">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<StatChip value={stats.total} label="Fakten" />
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">Sende das an Hermes</span>
|
||||||
<StatChip value={stats.auto} label="auto gelernt" accent="auto" />
|
<button onClick={copyPrompt} className="flex items-center gap-1 text-[10px] font-semibold text-muted-foreground hover:text-foreground transition-colors">
|
||||||
<StatChip value={stats.manual} label="manuell" />
|
{copied ? <><Check className="h-3 w-3 text-emerald-400" /> Kopiert</> : <><Copy className="h-3 w-3" /> Kopieren</>}
|
||||||
<StatChip value={stats.cats} label="Kategorien" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile-Steuerung (nur sichtbar unter lg) */}
|
|
||||||
{!empty && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 lg:hidden">
|
|
||||||
<div className="relative flex-1 min-w-[140px]">
|
|
||||||
<input
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => setQ(e.target.value)}
|
|
||||||
aria-label="Gedächtnis durchsuchen"
|
|
||||||
type="search"
|
|
||||||
placeholder="Suchen…"
|
|
||||||
className="w-full 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" />
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-1 p-1 bg-card/30 border border-border/40 rounded-lg">
|
|
||||||
{([["liste", List], ["graph", Share2]] as const).map(([v, Icon]) => (
|
|
||||||
<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" />
|
|
||||||
</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">
|
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<pre className="w-full rounded-xl border border-border/60 bg-background/40 p-3.5 text-[11px] leading-relaxed text-foreground/90 whitespace-pre-wrap font-mono">{ONBOARDING_PROMPT}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Eintrag anlegen (einklappbar) */}
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
{showAdd && (
|
<button onClick={goTerminal}
|
||||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 space-y-3">
|
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">
|
||||||
<div className="flex items-center justify-between">
|
<TerminalIcon className="h-4 w-4" /> Im Terminal starten
|
||||||
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Neuen Eintrag anlegen</div>
|
</button>
|
||||||
<button onClick={() => setShowAdd(false)} aria-label="Schließen" className="text-muted-foreground hover:text-foreground"><X className="h-4 w-4" aria-hidden="true" /></button>
|
<button onClick={copyPrompt}
|
||||||
</div>
|
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">
|
||||||
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2}
|
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />} Prompt kopieren
|
||||||
aria-label="Eintragstext"
|
</button>
|
||||||
placeholder="Eine Regel, Vorliebe oder einen stabilen Fakt über dich oder das Projekt…"
|
</div>
|
||||||
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" />
|
<p className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<Send className="h-3 w-3" /> Funktioniert genauso über Telegram —{" "}
|
||||||
<select value={category} onChange={(e) => setCategory(e.target.value)}
|
<button onClick={() => setShowAdd(true)} className="underline hover:text-foreground">oder lieber manuell anlegen</button>.
|
||||||
aria-label="Kategorie"
|
</p>
|
||||||
className="h-8 rounded-lg border border-border/60 bg-background/50 px-2 text-xs outline-none font-semibold text-foreground cursor-pointer">
|
</div>
|
||||||
{CATEGORIES.map((c) => <option key={c} value={c} className="bg-popover text-foreground">{CAT_CONFIG[c]?.label || c}</option>)}
|
) : (
|
||||||
</select>
|
/* Der voll integrierte, schwebende Wissens-Hub */
|
||||||
<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">
|
<div className="relative w-full h-[calc(100vh-8.5rem)] min-h-[620px] rounded-2xl border border-border/60 bg-[#030712] overflow-hidden flex flex-col lg:block">
|
||||||
<Plus className="h-4 w-4" /> Speichern
|
|
||||||
</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: {error}</div>}
|
{/* 1. GraphView nimmt auf Desktop 100% des Hintergrunds ein */}
|
||||||
|
<div className="absolute inset-0 z-0 hidden lg:block">
|
||||||
|
<GraphErrorBoundary>
|
||||||
|
<Suspense fallback={<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen…</div>}>
|
||||||
|
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
|
||||||
|
</Suspense>
|
||||||
|
</GraphErrorBoundary>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Empty-State oder Listen-Inhalte */}
|
{/* 2. Schwebendes linkes Panel (Wissens-Liste) */}
|
||||||
{empty ? (
|
<div className={cn(
|
||||||
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 sm:p-10 flex flex-col items-center text-center gap-5">
|
"z-10 flex flex-col transition-all duration-300",
|
||||||
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center">
|
"lg:absolute lg:left-4 lg:top-4 lg:bottom-4 lg:w-96 lg:rounded-xl lg:border lg:border-border/40 lg:bg-background/65 lg:backdrop-blur-md lg:p-4 lg:shadow-2xl lg:shadow-black/50 lg:h-[calc(100%-2rem)]",
|
||||||
<MessagesSquare className="h-7 w-7 text-primary" />
|
"w-full h-full p-4 flex-1 lg:flex-none",
|
||||||
</div>
|
view === "graph" && "hidden lg:flex"
|
||||||
<div className="space-y-1.5 max-w-lg">
|
)}>
|
||||||
<h3 className="text-base font-semibold text-foreground">Lass Hermes dich kennenlernen</h3>
|
{/* Header & Steuerung direkt im Panel */}
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<div className="space-y-3 mb-3 shrink-0">
|
||||||
Statt Fakten einzutippen: führ ein kurzes Onboarding-Gespräch mit Hermes. Was ihr besprecht,
|
<div>
|
||||||
merkt sich das Gedächtnis automatisch — du musst nichts manuell pflegen.
|
<h1 className="text-xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
|
||||||
|
Gedächtnis-Pool
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed mt-0.5">
|
||||||
|
IDEs & Gateway lesen und lernen hier per MCP.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vorgeschlagener Prompt */}
|
{/* Statistiken (Chips) */}
|
||||||
<div className="w-full max-w-lg text-left">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<span className="text-[9px] font-semibold bg-primary/10 text-primary border border-primary/20 px-2 py-0.5 rounded-md">{stats.total} Fakten</span>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">Sende das an Hermes</span>
|
<span className="text-[9px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded-md">{stats.auto} auto</span>
|
||||||
<button onClick={copyPrompt} className="flex items-center gap-1 text-[10px] font-semibold text-muted-foreground hover:text-foreground transition-colors">
|
<span className="text-[9px] font-semibold bg-card/60 text-muted-foreground border border-border/40 px-2 py-0.5 rounded-md">{stats.cats} Kat.</span>
|
||||||
{copied ? <><Check className="h-3 w-3 text-emerald-400" /> Kopiert</> : <><Copy className="h-3 w-3" /> Kopieren</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre className="w-full rounded-xl border border-border/60 bg-background/40 p-3.5 text-[11px] leading-relaxed text-foreground/90 whitespace-pre-wrap font-mono">{ONBOARDING_PROMPT}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
{/* Steuerungsleiste: Suchen, Eintrag, Deduplizieren */}
|
||||||
<button onClick={goTerminal}
|
<div className="flex items-center gap-1.5 w-full">
|
||||||
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">
|
<div className="relative flex-1">
|
||||||
<TerminalIcon className="h-4 w-4" /> Im Terminal starten
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
aria-label="Gedächtnis durchsuchen"
|
||||||
|
type="search"
|
||||||
|
placeholder="Semantisch suchen…"
|
||||||
|
className="w-full h-8 pl-8 pr-3 rounded-lg border border-border/50 bg-background/40 text-[11px] 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)} title="Eintrag hinzufügen"
|
||||||
|
className="h-8 w-8 shrink-0 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center justify-center cursor-pointer shadow-md shadow-primary/10">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={copyPrompt}
|
<button onClick={cleanup} disabled={deduping} title="Deduplizieren"
|
||||||
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">
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-card/45 hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50">
|
||||||
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />} Prompt kopieren
|
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Mobile View Switcher */}
|
||||||
|
<div className="flex items-center gap-1 p-0.5 bg-card/30 border border-border/40 rounded-lg lg:hidden">
|
||||||
|
{([["liste", List], ["graph", Share2]] as const).map(([v, Icon]) => (
|
||||||
|
<button key={v} onClick={() => setView(v)}
|
||||||
|
className={cn("flex h-7 w-7 items-center justify-center rounded-md transition-all cursor-pointer",
|
||||||
|
view === v ? "bg-primary text-primary-foreground" : "text-muted-foreground")}>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5">
|
|
||||||
<Send className="h-3 w-3" /> Funktioniert genauso über Telegram —{" "}
|
|
||||||
<button onClick={() => setShowAdd(true)} className="underline hover:text-foreground">oder lieber manuell anlegen</button>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={cn("space-y-5", view === "graph" && "hidden lg:block")}>
|
|
||||||
{/* Kategorie-Filter */}
|
{/* 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">
|
<div className="flex flex-wrap items-center gap-1 p-0.5 bg-card/30 border border-border/40 rounded-lg w-full overflow-x-auto shrink-0 scrollbar-none">
|
||||||
<button onClick={() => setFilter("")}
|
<button onClick={() => setFilter("")}
|
||||||
className={cn("h-7 px-3 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer",
|
className={cn("h-6 px-2 rounded-md text-[9px] font-semibold uppercase tracking-wider transition-all cursor-pointer shrink-0",
|
||||||
!filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>Alle</button>
|
!filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>Alle</button>
|
||||||
{CATEGORIES.map((c) => {
|
{CATEGORIES.map((c) => {
|
||||||
const conf = CAT_CONFIG[c] || DEFAULT_CAT
|
const conf = CAT_CONFIG[c] || DEFAULT_CAT
|
||||||
const Icon = conf.icon
|
|
||||||
return (
|
return (
|
||||||
<button key={c} onClick={() => setFilter(c)}
|
<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",
|
className={cn("h-6 px-2 rounded-md text-[9px] font-semibold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1 shrink-0",
|
||||||
filter === c ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
|
filter === c ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
|
||||||
<Icon className="h-3 w-3" /> {conf.label}
|
{conf.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Listenelemente (Scrollbar im Glaspanel) */}
|
||||||
|
<div className="flex-1 overflow-y-auto pr-1 scrollbar-thin space-y-4">
|
||||||
|
{showAdd && (
|
||||||
|
<div className="rounded-xl border border-border/50 bg-background/50 p-3 space-y-2 shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Neuer Eintrag</div>
|
||||||
|
<button onClick={() => setShowAdd(false)} className="text-muted-foreground hover:text-foreground"><X className="h-3.5 w-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2}
|
||||||
|
placeholder="Vorliebe, Regel oder Fakt…"
|
||||||
|
className="w-full resize-none rounded-lg border border-border/50 bg-background/30 p-2 text-[11px] outline-none focus:ring-1 focus:ring-primary/50 text-foreground leading-relaxed" />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<select value={category} onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-border/50 bg-background/50 px-1 text-[10px] outline-none 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-7 px-2.5 rounded-md bg-primary text-primary-foreground text-[10px] font-semibold hover:opacity-90 transition-all cursor-pointer">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="rounded-lg border border-red-500/20 bg-red-500/5 p-3 text-[10px] text-red-400">Fehler: {error}</div>}
|
||||||
|
|
||||||
{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 bg-card/10">
|
<div className="text-[11px] text-muted-foreground border border-dashed border-border/60 rounded-xl p-8 text-center bg-card/10">
|
||||||
Keine Einträge für die aktuellen Filterkriterien gefunden.
|
Keine Einträge gefunden.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5 max-h-[calc(100vh-14rem)] overflow-y-auto pr-1 scrollbar-thin">
|
<div className="space-y-4">
|
||||||
{[...CATEGORIES, "__other"].map((c) => {
|
{[...CATEGORIES, "__other"].map((c) => {
|
||||||
const list = c === "__other" ? otherItems : (grouped[c] || [])
|
const list = c === "__other" ? otherItems : (grouped[c] || [])
|
||||||
if (!list.length) return null
|
if (!list.length) return null
|
||||||
const conf = CAT_CONFIG[c] || DEFAULT_CAT
|
const conf = CAT_CONFIG[c] || DEFAULT_CAT
|
||||||
const Icon = conf.icon
|
const Icon = conf.icon
|
||||||
return (
|
return (
|
||||||
<div key={c} className="space-y-2">
|
<div key={c} className="space-y-1.5">
|
||||||
{/* Sektions-Kopf je Kategorie */}
|
<div className="flex items-center gap-1.5 px-0.5">
|
||||||
<div className="flex items-center gap-2 mb-2 px-1">
|
<Icon className={cn("h-3 w-3", conf.text)} />
|
||||||
<Icon className={cn("h-3.5 w-3.5", conf.text)} />
|
<span className={cn("text-[9px] font-bold uppercase tracking-wider", conf.text)}>{conf.label}</span>
|
||||||
<span className={cn("text-[11px] font-bold uppercase tracking-wider", conf.text)}>{conf.label}</span>
|
<span className="text-[9px] font-mono text-muted-foreground/60 bg-card/50 rounded px-1">{list.length}</span>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60 bg-card/50 rounded px-1.5 py-0.5">{list.length}</span>
|
|
||||||
<div className="ml-1 h-px flex-1 bg-border/30" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{list.map((m) => {
|
{list.map((m) => {
|
||||||
const isAuto = AUTO_SOURCES.has(m.source)
|
const isAuto = AUTO_SOURCES.has(m.source)
|
||||||
|
const isSelected = selected === m.id
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className={cn("flex items-start justify-between gap-4 p-3.5 rounded-xl border border-l-4 bg-card/45 backdrop-blur-md border-border/60 hover:border-primary/20 transition-all group",
|
<div key={m.id} onClick={() => setSelected(isSelected ? null : m.id)}
|
||||||
BORDER_CLASSES[m.category] || "border-l-muted")}>
|
className={cn(
|
||||||
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span>
|
"flex items-start justify-between gap-3 p-2.5 rounded-lg border border-l-4 transition-all group cursor-pointer text-left",
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
isSelected ? "bg-primary/10 border-primary shadow-md" : "bg-card/45 border-border/40 hover:bg-card/75",
|
||||||
{typeof m.score === "number" && (
|
BORDER_CLASSES[m.category] || "border-l-muted"
|
||||||
<span className="text-[9px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded" title="Relevanz der semantischen Suche">{Math.round(m.score * 100)}%</span>
|
)}>
|
||||||
)}
|
<span className="text-[11px] text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span>
|
||||||
<span className={cn("flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded",
|
<div className="flex items-center gap-1.5 shrink-0 self-center">
|
||||||
isAuto ? "text-emerald-400 bg-emerald-500/10" : "text-muted-foreground/60 bg-background/20")}
|
{isAuto && <span title="Auto gelernt"><Sparkles className="h-2.5 w-2.5 text-emerald-400" /></span>}
|
||||||
title={isAuto ? "Automatisch gelernt" : "Manuell angelegt"}>
|
<button onClick={(e) => { e.stopPropagation(); del(m.id); if (selected === m.id) setSelected(null) }}
|
||||||
{isAuto && <Sparkles className="h-2.5 w-2.5" />}{m.source}
|
className="h-5 w-5 rounded-md 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">
|
||||||
</span>
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<button onClick={() => del(m.id)} aria-label="Eintrag löschen" 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" aria-hidden="true" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,54 +361,64 @@ export function MemoryView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rechte Spalte (Steuerungszeile & Graph-Bereich) */}
|
{/* 3. Mobiler Fallback für den Graph (unter lg) */}
|
||||||
{!empty && (
|
<div className={cn("w-full h-[500px] relative lg:hidden", view === "liste" && "hidden")}>
|
||||||
<div className="flex-1 w-full space-y-5">
|
<GraphErrorBoundary>
|
||||||
{/* Desktop-Steuerungszeile (nur sichtbar ab lg) */}
|
<Suspense fallback={<div className="h-full rounded-2xl border border-border/60 bg-card/30 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen…</div>}>
|
||||||
<div className="hidden lg:flex items-center justify-between gap-3 h-12">
|
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
|
||||||
<span className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5">
|
</Suspense>
|
||||||
<Sparkles className="h-3 w-3 text-emerald-400" /> lernt automatisch aus Hermes-Gesprächen
|
</GraphErrorBoundary>
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* 4. Schwebendes rechtes Panel (Details zum ausgewählten Knoten) */}
|
||||||
<div className="relative">
|
{sel && (
|
||||||
<input
|
<div className={cn(
|
||||||
value={q}
|
"z-10 flex flex-col transition-all duration-300",
|
||||||
onChange={(e) => setQ(e.target.value)}
|
"lg:absolute lg:right-4 lg:top-4 lg:bottom-4 lg:w-80 lg:rounded-xl lg:border lg:border-border/40 lg:bg-background/65 lg:backdrop-blur-md lg:p-4 lg:shadow-2xl lg:shadow-black/50 lg:h-[calc(100%-2rem)]",
|
||||||
aria-label="Gedächtnis durchsuchen"
|
"w-full p-4 border-t border-border/60 bg-card/45 shrink-0 flex flex-col"
|
||||||
type="search"
|
)}>
|
||||||
placeholder="Semantisch suchen…"
|
<div className="flex items-center gap-2 mb-3 shrink-0">
|
||||||
className="w-56 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"
|
<span className="w-2.5 h-2.5 rounded-full" style={{ background: CAT_COLOR_MAP[sel.category] }} />
|
||||||
/>
|
<span className="text-[9px] font-bold uppercase tracking-wider" style={{ color: CAT_COLOR_MAP[sel.category] }}>
|
||||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
{CAT_LABEL_MAP[sel.category] || sel.category}
|
||||||
|
</span>
|
||||||
|
<span className={`ml-auto text-[9px] font-mono flex items-center gap-1 ${AUTO.has(sel.source) ? "text-emerald-400" : "text-muted-foreground/70"}`}>
|
||||||
|
{AUTO.has(sel.source) && <Sparkles className="h-2.5 w-2.5" />}
|
||||||
|
{sel.source}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setSelected(null)} className="text-muted-foreground hover:text-foreground ml-1.5 cursor-pointer"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto pr-1 scrollbar-thin text-xs text-foreground leading-relaxed mb-4 break-words whitespace-pre-wrap text-left">
|
||||||
|
{sel.content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto shrink-0 space-y-3 pt-3 border-t border-border/30">
|
||||||
|
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 flex items-center gap-1">
|
||||||
|
<Share2 className="h-2.5 w-2.5" /> verwandte Fakten
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowAdd((s) => !s)}
|
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto scrollbar-none text-left">
|
||||||
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">
|
{neighbors.length ? neighbors.map((n) => (
|
||||||
<Plus className="h-4 w-4" /> Eintrag
|
<button key={n.id} onClick={() => setSelected(n.id)}
|
||||||
</button>
|
className="flex items-center gap-2 text-[10px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer py-0.5">
|
||||||
<button onClick={cleanup} disabled={deduping}
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR_MAP[n.category] }} />
|
||||||
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"
|
<span className="truncate">{n.content}</span>
|
||||||
title="Deduplizieren">
|
</button>
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
)) : <span className="text-[10px] text-muted-foreground/60">—</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={() => { del(sel.id); setSelected(null) }}
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 text-[10px] font-bold text-red-400 border border-red-500/20 rounded-lg py-2 hover:bg-red-500/5 transition-all cursor-pointer">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Eintrag vergessen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Graph-Sicht */}
|
</div>
|
||||||
<div className={cn("w-full", view === "liste" && "hidden lg:block")}>
|
)}
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dialogElement}
|
{dialogElement}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user