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:
@@ -6,6 +6,7 @@ import { ModelsView } from "@/views/ModelsView"
|
||||
import { RoutingView } from "@/views/RoutingView"
|
||||
import { SystemView } from "@/views/SystemView"
|
||||
import { ConnectView } from "@/views/ConnectView"
|
||||
import { MemoryView } from "@/views/MemoryView"
|
||||
import { Placeholder } from "@/views/Placeholder"
|
||||
import { api, type Health } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -94,7 +95,8 @@ export default function App() {
|
||||
{view === "routing" && <RoutingView />}
|
||||
{view === "system" && <SystemView />}
|
||||
{view === "connect" && <ConnectView />}
|
||||
{!["models", "routing", "system", "connect"].includes(view) && (
|
||||
{view === "memory" && <MemoryView />}
|
||||
{!["models", "routing", "system", "connect", "memory"].includes(view) && (
|
||||
<Placeholder title={active.label} hint={active.hint} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -100,6 +100,22 @@ export interface ConnectResp {
|
||||
tools: Record<string, ConnectTool>
|
||||
}
|
||||
|
||||
export interface Memory {
|
||||
id: string
|
||||
content: string
|
||||
category: string
|
||||
source: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DedupeResult {
|
||||
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
|
||||
duplicate_count: number
|
||||
removed: number
|
||||
applied: boolean
|
||||
}
|
||||
|
||||
export interface Health {
|
||||
status: string
|
||||
version: string
|
||||
|
||||
@@ -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