feat(2.0): Phase 3 — Memory + MCP
Geteiltes Gedaechtnis: services/memory.py (SQLite/WAL, 5 Kategorien, Dedupe-Kurator deterministisch), routers/memory.py (CRUD/export/dedupe). MCP: mcp/mcp_memory.py (Guard-Beschreibungen gegen 14B-Loop) + mcp/mcp_mc.py NEU (Stack-Management fuer Hermes: list/discover/register/route/restart/ status). Frontend MemoryView (Add/Filter/Suche/Delete/Aufraeumen). Lokal verifiziert: CRUD + Dedupe (TestClient), MCP-Server syntax-OK, Frontend-Build + Browser (MemoryView). Docs aktualisiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Trash2, Sparkles } from "lucide-react"
|
||||
import { api, type DedupeResult, type Memory } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
|
||||
const CAT_LABEL: Record<string, string> = {
|
||||
user: "👤 User", instruction: "📋 Regel", stable: "🔵 Fakt",
|
||||
versioned: "🟡 Version", ephemeral: "⏱ Temporär",
|
||||
}
|
||||
|
||||
export function MemoryView() {
|
||||
const [items, setItems] = useState<Memory[]>([])
|
||||
const [filter, setFilter] = useState("")
|
||||
const [q, setQ] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [category, setCategory] = useState("stable")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
function load() {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set("q", q)
|
||||
if (filter) params.set("category", filter)
|
||||
api<Memory[]>(`/api/memory?${params}`).then(setItems).catch((e) => setError(String(e)))
|
||||
}
|
||||
useEffect(load, [q, filter])
|
||||
|
||||
async function add() {
|
||||
if (!content.trim()) return
|
||||
await api("/api/memory", { method: "POST", body: JSON.stringify({ content, category, source: "ui" }) })
|
||||
setContent("")
|
||||
load()
|
||||
}
|
||||
async function del(id: string) {
|
||||
await api(`/api/memory/${id}`, { method: "DELETE" })
|
||||
load()
|
||||
}
|
||||
async function cleanup() {
|
||||
const dry = await api<DedupeResult>("/api/memory/dedupe", {
|
||||
method: "POST", body: JSON.stringify({ apply: false }),
|
||||
})
|
||||
if (dry.duplicate_count === 0) {
|
||||
alert("Keine Dubletten gefunden — alles sauber.")
|
||||
return
|
||||
}
|
||||
if (confirm(`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`)) {
|
||||
await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) })
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Gedächtnis</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Die geteilte „Verfassung" — alle Tools (Hermes, IDEs) lesen/schreiben hier via MCP.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={cleanup}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary" /> Aufräumen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add */}
|
||||
<div className="rounded-xl border border-border bg-card p-3">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Neuen Fakt / Regel hinzufügen…"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none"
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{CAT_LABEL[c]}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={add} className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Suchen…"
|
||||
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFilter("")}
|
||||
className={cn("rounded-md px-2.5 py-1.5 text-xs", !filter ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setFilter(c)}
|
||||
className={cn("rounded-md px-2.5 py-1.5 text-xs", filter === c ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
{CAT_LABEL[c]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-muted-foreground">Fehler: {error}</div>}
|
||||
|
||||
{/* List */}
|
||||
<div className="space-y-2">
|
||||
{items.length === 0 && <div className="text-sm text-muted-foreground">Keine Einträge.</div>}
|
||||
{items.map((m) => (
|
||||
<div key={m.id} className="flex items-start gap-3 rounded-lg border border-border bg-card p-3">
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{CAT_LABEL[m.category] || m.category}
|
||||
</span>
|
||||
<span className="flex-1 text-sm">{m.content}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{m.source}</span>
|
||||
<button onClick={() => del(m.id)} className="shrink-0 text-muted-foreground hover:text-red-500">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user