Feat: Gedächtnis-Tab überarbeitet — Sigma.js-Graph (skaliert), Liste als Default + gruppiert
Graph: reagraph (three.js, schwer, hing Renderer) → Sigma.js v3 + graphology (graph-optimiertes WebGL, skaliert auf tausende Knoten), im App-Look: Kategorie-Farben, Knotengröße nach Verknüpfungen, Hover-Highlight (Nachbarn hervor, Rest dimmt), Legende. Liste ist jetzt Default + nach Kategorie gruppierte Sektionen (statt flacher Wand). reagraph deinstalliert → leichteres Bundle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText, Package, GitCommit, ExternalLink, ArrowRight } from "lucide-react"
|
||||
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText, Package, GitCommit, ExternalLink, ArrowRight, Shuffle } from "lucide-react"
|
||||
import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CustomDialog } from "./CustomDialog"
|
||||
@@ -81,7 +81,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
const [hermesUpdating, setHermesUpdating] = useState(false)
|
||||
|
||||
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
|
||||
const [detail, setDetail] = useState<{ kind: "os" | "engine" | "hermes"; loading: boolean; data: UpdateDetails | null } | null>(null)
|
||||
const [detail, setDetail] = useState<{ kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null } | null>(null)
|
||||
|
||||
// Custom Dialog State
|
||||
const [dialog, setDialog] = useState<{
|
||||
@@ -237,6 +237,16 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSwapUpdate() {
|
||||
try {
|
||||
await api<{ job_id: string }>("/api/maintenance/swap-update", { method: "POST" })
|
||||
loadJobs()
|
||||
setActiveTab("maintenance")
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Router-Update: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function doHermesUpdate() {
|
||||
setHermesUpdating(true)
|
||||
try {
|
||||
@@ -250,7 +260,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
}
|
||||
|
||||
// Öffnet das Detail-Fenster und lädt, was genau aktualisiert würde.
|
||||
async function openUpdateDetails(kind: "os" | "engine" | "hermes") {
|
||||
async function openUpdateDetails(kind: "os" | "engine" | "swap" | "hermes") {
|
||||
setDetail({ kind, loading: true, data: null })
|
||||
try {
|
||||
const d = await api<UpdateDetails>(`/api/maintenance/update-details?kind=${kind}`)
|
||||
@@ -266,6 +276,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
setDetail(null)
|
||||
if (kind === "os") triggerOsUpdate()
|
||||
else if (kind === "engine") triggerEngineUpdate()
|
||||
else if (kind === "swap") triggerSwapUpdate()
|
||||
else if (kind === "hermes") doHermesUpdate()
|
||||
}
|
||||
|
||||
@@ -446,7 +457,8 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<UpdateRow icon={Shield} iconClass="text-cyan-400" name="OS-Pakete (apt)" available={!!updates?.os} status={updates?.os ? `${updates.os} verfügbar` : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("os")} />
|
||||
<UpdateRow icon={Server} iconClass="text-violet-400" name="Engine (llama.cpp)" available={!!updates?.engine} status={updates?.engine ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("engine")} />
|
||||
<UpdateRow icon={Server} iconClass="text-violet-400" name="Inferenz-Engine (llama.cpp)" available={!!updates?.engine} status={updates?.engine ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("engine")} />
|
||||
<UpdateRow icon={Shuffle} iconClass="text-fuchsia-400" name="Router (llama-swap)" available={!!updates?.swap} status={updates?.swap ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("swap")} />
|
||||
{(() => {
|
||||
const h = updates?.components?.find((c) => c.key === "hermes_agent")
|
||||
return (
|
||||
@@ -764,7 +776,8 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
const d = detail.data
|
||||
const meta = {
|
||||
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
|
||||
engine: { icon: Server, cls: "text-violet-400", title: "Engine (llama.cpp)" },
|
||||
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
|
||||
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
|
||||
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
|
||||
}[detail.kind]
|
||||
const Icon = meta.icon
|
||||
@@ -815,7 +828,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : detail.kind === "engine" ? (
|
||||
) : detail.kind === "engine" || detail.kind === "swap" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
|
||||
|
||||
@@ -41,8 +41,10 @@ export function UpdatesCard() {
|
||||
<div className="space-y-1.5">
|
||||
<StatusRow label="OS-Pakete" tone={updates.os > 0 ? "alert" : "muted"}
|
||||
value={updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"} />
|
||||
<StatusRow label="Engine (llama.cpp)" tone={updates.engine > 0 ? "alert" : "muted"}
|
||||
<StatusRow label="Inferenz-Engine (llama.cpp)" tone={updates.engine > 0 ? "alert" : "muted"}
|
||||
value={updates.engine > 0 ? "Update verfügbar" : "aktuell"} />
|
||||
<StatusRow label="Router (llama-swap)" tone={updates.swap > 0 ? "alert" : "muted"}
|
||||
value={updates.swap > 0 ? "Update verfügbar" : "aktuell"} />
|
||||
<StatusRow label="Modell-Upgrades" tone={updates.models > 0 ? "accent" : "muted"}
|
||||
value={updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"} />
|
||||
{updates.components?.map((c) => (
|
||||
|
||||
@@ -242,6 +242,7 @@ export interface ComponentUpdate {
|
||||
export interface UpdatesResp {
|
||||
os: number
|
||||
engine: number
|
||||
swap: number
|
||||
models: number
|
||||
model_list: { role: string; title: string; repo: string }[]
|
||||
last_check?: number | null
|
||||
@@ -249,7 +250,7 @@ export interface UpdatesResp {
|
||||
}
|
||||
|
||||
export interface UpdateDetails {
|
||||
kind: "os" | "engine" | "hermes"
|
||||
kind: "os" | "engine" | "swap" | "hermes"
|
||||
error?: string
|
||||
// os
|
||||
count?: number
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { GraphCanvas, darkTheme } from "reagraph"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import Graph from "graphology"
|
||||
import forceAtlas2 from "graphology-layout-forceatlas2"
|
||||
import Sigma from "sigma"
|
||||
import { Trash2, Sparkles, Share2 } from "lucide-react"
|
||||
import { type MemoryGraph } from "@/lib/api"
|
||||
|
||||
// Sigma.js v3 + graphology: graph-optimiertes WebGL (kein three.js-Ballast) → skaliert auf tausende
|
||||
// Fakten flüssig. Im App-Look gestylt; Hover hebt den Knoten + seine Nachbarn hervor, der Rest dimmt.
|
||||
|
||||
const CAT_COLOR: Record<string, string> = {
|
||||
identity: "#22d3ee", knowledge: "#6366f1", rules: "#a78bfa", events: "#fbbf24",
|
||||
}
|
||||
@@ -10,9 +15,18 @@ const CAT_LABEL: Record<string, string> = {
|
||||
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
|
||||
}
|
||||
const AUTO = new Set(["auto", "agent", "hermes"])
|
||||
const DIM_NODE = "#1f2937"
|
||||
const DIM_EDGE = "#0f172a"
|
||||
const EDGE = "#243044"
|
||||
const EDGE_HI = "#4b5563"
|
||||
|
||||
export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const sigmaRef = useRef<Sigma | null>(null)
|
||||
const graphRef = useRef<Graph | null>(null)
|
||||
const active = useRef<string | null>(null) // hervorgehobener Knoten (hover oder Auswahl)
|
||||
const selectedRef = useRef<string | null>(null) // aktuelle Auswahl (für leaveNode ohne Effekt-Neulauf)
|
||||
|
||||
const degree = useMemo(() => {
|
||||
const d: Record<string, number> = {}
|
||||
@@ -20,19 +34,67 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
|
||||
return d
|
||||
}, [data])
|
||||
|
||||
const nodes = useMemo(() => data.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
label: n.content.length > 26 ? n.content.slice(0, 25) + "…" : n.content,
|
||||
fill: CAT_COLOR[n.category] || "#64748b",
|
||||
size: 6 + Math.min(degree[n.id] || 0, 6) * 2,
|
||||
})), [data, degree])
|
||||
// Graph (neu)bauen + Layout + Sigma rendern, wenn sich die Daten ändern.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !data.nodes.length) return
|
||||
const graph = new Graph()
|
||||
const N = data.nodes.length
|
||||
data.nodes.forEach((n, i) => {
|
||||
const a = (2 * Math.PI * i) / N
|
||||
graph.addNode(n.id, {
|
||||
x: Math.cos(a), y: Math.sin(a), // Startposition (Kreis) für ForceAtlas2
|
||||
size: 4 + Math.min(degree[n.id] || 0, 12) * 1.4,
|
||||
color: CAT_COLOR[n.category] || "#64748b",
|
||||
label: n.content.length > 48 ? n.content.slice(0, 47) + "…" : n.content,
|
||||
})
|
||||
})
|
||||
data.edges.forEach((e) => {
|
||||
if (graph.hasNode(e.source) && graph.hasNode(e.target) && !graph.hasEdge(e.source, e.target))
|
||||
graph.addEdge(e.source, e.target, { size: 0.6 + (e.weight || 0), color: EDGE })
|
||||
})
|
||||
forceAtlas2.assign(graph, { iterations: 220, settings: forceAtlas2.inferSettings(graph) })
|
||||
graphRef.current = graph
|
||||
|
||||
const edges = useMemo(() => data.edges.map((e) => ({
|
||||
id: `${e.source}->${e.target}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
size: 0.4 + e.weight,
|
||||
})), [data])
|
||||
const renderer = new Sigma(graph, containerRef.current, {
|
||||
renderLabels: true,
|
||||
labelColor: { color: "#cbd5e1" },
|
||||
labelSize: 11,
|
||||
labelWeight: "500",
|
||||
labelDensity: 0.6,
|
||||
labelRenderedSizeThreshold: N > 120 ? 12 : 0, // bei vielen Knoten nur große labeln (entklumpen)
|
||||
defaultEdgeColor: EDGE,
|
||||
minCameraRatio: 0.1,
|
||||
maxCameraRatio: 4,
|
||||
nodeReducer: (id, attrs) => {
|
||||
const a = active.current
|
||||
if (!a) return attrs
|
||||
if (id === a || graph.areNeighbors(a, id)) return attrs
|
||||
return { ...attrs, color: DIM_NODE, label: "" }
|
||||
},
|
||||
edgeReducer: (edge, attrs) => {
|
||||
const a = active.current
|
||||
if (!a) return attrs
|
||||
const ext = graph.extremities(edge)
|
||||
if (ext[0] === a || ext[1] === a) return { ...attrs, color: EDGE_HI, size: (attrs.size || 1) * 1.6 }
|
||||
return { ...attrs, color: DIM_EDGE }
|
||||
},
|
||||
})
|
||||
sigmaRef.current = renderer
|
||||
|
||||
renderer.on("clickNode", ({ node }) => setSelected(node))
|
||||
renderer.on("clickStage", () => setSelected(null))
|
||||
renderer.on("enterNode", ({ node }) => { active.current = node; renderer.refresh(); containerRef.current!.style.cursor = "pointer" })
|
||||
renderer.on("leaveNode", () => { active.current = selectedRef.current; renderer.refresh(); containerRef.current!.style.cursor = "default" })
|
||||
|
||||
return () => { renderer.kill(); sigmaRef.current = null; graphRef.current = null }
|
||||
}, [data, degree])
|
||||
|
||||
// Auswahl spiegelt die Hervorhebung (auch ohne Hover).
|
||||
useEffect(() => {
|
||||
selectedRef.current = selected
|
||||
active.current = selected
|
||||
sigmaRef.current?.refresh()
|
||||
}, [selected])
|
||||
|
||||
const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
|
||||
const neighbors = useMemo(() => {
|
||||
@@ -56,17 +118,15 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-3">
|
||||
<div className="relative h-[480px] rounded-2xl border border-border/60 bg-[#070a0f] overflow-hidden">
|
||||
<GraphCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
theme={darkTheme}
|
||||
layoutType="forceDirected2d"
|
||||
labelType="nodes"
|
||||
edgeArrowPosition="none"
|
||||
draggable
|
||||
onNodeClick={(n: any) => setSelected(n.id)}
|
||||
onCanvasClick={() => setSelected(null)}
|
||||
/>
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
{/* Legende */}
|
||||
<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">
|
||||
{Object.entries(CAT_LABEL).map(([k, l]) => (
|
||||
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 min-h-[480px]">
|
||||
@@ -87,20 +147,15 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 mb-4">
|
||||
{neighbors.length ? neighbors.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => setSelected(n.id)}
|
||||
className="flex items-center gap-2 text-[11px] text-muted-foreground hover:text-foreground text-left transition-colors"
|
||||
>
|
||||
<button key={n.id} onClick={() => setSelected(n.id)}
|
||||
className="flex items-center gap-2 text-[11px] text-muted-foreground hover:text-foreground text-left transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR[n.category] }} />
|
||||
<span className="truncate">{n.content}</span>
|
||||
</button>
|
||||
)) : <span className="text-[11px] text-muted-foreground/60">—</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { onDelete(sel.id); setSelected(null) }}
|
||||
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"
|
||||
>
|
||||
<button onClick={() => { onDelete(sel.id); setSelected(null) }}
|
||||
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">
|
||||
<Trash2 className="h-3.5 w-3.5" /> vergessen
|
||||
</button>
|
||||
</>
|
||||
@@ -108,7 +163,7 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
|
||||
<div className="h-full flex flex-col items-center justify-center text-center text-muted-foreground/70 gap-2 pt-24">
|
||||
<Share2 className="h-6 w-6" />
|
||||
<div className="text-xs max-w-[180px] leading-relaxed">
|
||||
Einen Knoten wählen, um den Fakt und seine semantischen Nachbarn zu sehen.
|
||||
Auf einen Knoten klicken, um den Fakt und seine semantischen Nachbarn zu sehen. Hover hebt das Netz hervor.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { GraphErrorBoundary } from "./GraphErrorBoundary"
|
||||
|
||||
// reagraph + three.js sind schwer → erst laden, wenn der Graph-Tab geöffnet wird.
|
||||
// Sigma/graphology nur laden, wenn der Graph-Tab geöffnet wird (Code-Splitting).
|
||||
const GraphView = lazy(() => import("./GraphView").then((m) => ({ default: m.GraphView })))
|
||||
|
||||
const CATEGORIES = ["identity", "knowledge", "rules", "events"]
|
||||
@@ -48,7 +48,7 @@ export function MemoryView() {
|
||||
const [category, setCategory] = useState("knowledge")
|
||||
const [deduping, setDeduping] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [view, setView] = useState<"liste" | "graph">("graph")
|
||||
const [view, setView] = useState<"liste" | "graph">("liste")
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
|
||||
const qc = useQueryClient()
|
||||
@@ -80,6 +80,14 @@ export function MemoryView() {
|
||||
return { nodes: ns, edges: g.edges.filter((e) => ids.has(e.source) && ids.has(e.target)) }
|
||||
}, [graph, q])
|
||||
|
||||
// Liste nach Kategorie gruppieren (skaliert: klare Sektionen statt einer flachen Wand).
|
||||
const grouped = useMemo(() => {
|
||||
const g: Record<string, (typeof items)[number][]> = {}
|
||||
items.forEach((m) => { (g[m.category] ??= []).push(m) })
|
||||
return g
|
||||
}, [items])
|
||||
const otherItems = useMemo(() => items.filter((m) => !CATEGORIES.includes(m.category)), [items])
|
||||
|
||||
async function add() {
|
||||
if (!content.trim()) return
|
||||
await api("/api/memory", { method: "POST", body: JSON.stringify({ content, category, source: "ui" }) })
|
||||
@@ -277,42 +285,56 @@ export function MemoryView() {
|
||||
})}
|
||||
</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>
|
||||
) : items.map((m) => {
|
||||
const conf = CAT_CONFIG[m.category] || DEFAULT_CAT
|
||||
const Icon = conf.icon
|
||||
const isAuto = AUTO_SOURCES.has(m.source)
|
||||
return (
|
||||
<div key={m.id} className={cn("flex items-start justify-between gap-4 p-4 rounded-xl border border-l-4 bg-card/45 backdrop-blur-md border-border/60 shadow-md shadow-black/5 hover:border-primary/20 transition-all group",
|
||||
BORDER_CLASSES[m.category] || "border-l-muted")}>
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<span className={cn("flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider font-mono shrink-0", conf.bg, conf.text)}>
|
||||
<Icon className="h-3 w-3" /><span className="hidden sm:inline">{conf.label}</span>
|
||||
</span>
|
||||
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1">{m.content}</span>
|
||||
{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 className="space-y-5">
|
||||
{[...CATEGORIES, "__other"].map((c) => {
|
||||
const list = c === "__other" ? otherItems : (grouped[c] || [])
|
||||
if (!list.length) return null
|
||||
const conf = CAT_CONFIG[c] || DEFAULT_CAT
|
||||
const Icon = conf.icon
|
||||
return (
|
||||
<div key={c}>
|
||||
{/* Sektions-Kopf je Kategorie */}
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<Icon className={cn("h-3.5 w-3.5", conf.text)} />
|
||||
<span className={cn("text-[11px] font-bold uppercase tracking-wider", conf.text)}>{conf.label}</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 className="space-y-2">
|
||||
{list.map((m) => {
|
||||
const isAuto = AUTO_SOURCES.has(m.source)
|
||||
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",
|
||||
BORDER_CLASSES[m.category] || "border-l-muted")}>
|
||||
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{typeof m.score === "number" && (
|
||||
<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={cn("flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded",
|
||||
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>
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user