feat(agent-memory): UI fuer Agent-Memories mit semantischer Suche — Modul 8/16
Check Cross-Plugin Imports / check (push) Has been cancelled

Drittes Modul via Phase-Q-Manifest-Architektur: Registrierung komplett
ueber das agent_memory-Plugin-Manifest (page_route /agent-memory +
menu_item, Brain-Icon) — routes/index.tsx unangetastet. Komponenten-Map
mit 40 Eintraegen.

Zusaetzlicher Fix: Sidebar-ICON_MAP um Brain, Store, Tags erweitert —
Marketplace (Store) und Tags zeigten bisher Fallback-Icons, weil die
Manifest-Icons nicht in der kuratierten Map standen.

Backend existierte vollstaendig (create mit Embedding, list mit
agent_id-Pflichtfilter + Typ + Pagination, semantische Suche via pgvector,
update mit Embedding-Regeneration, delete; agent_memory:read/write),
Frontend hatte 0% Abdeckung.

- api/agentMemory.ts: TanStack-Hooks (useAgentMemories mit
  enabled-Gating, useAgentMemorySearch, create/update/delete)
- pages/AgentMemory.tsx: Agent-Picker (Pflichtfeld — Backend filtert
  zwingend nach agent_id), Pick-Agent-Prompt, semantische Suche mit
  Relevanz-Score-Badges und Clear, Memory-Karten mit Typ-Badges
  (fact/context/pattern/instruction), Create/Edit-Dialog, Delete mit
  Confirm — Aktionen hinter agent_memory:write
- i18n agentMemory.* + nav.agentMemory de/en

Verifikation: Vitest 11/11 (Agent-Picker-Pflicht, Pick-Prompt, Badges,
Suche mit Score + Clear, Create/Edit prefilled, Delete mit+ohne Confirm,
Gating, Empty/Error) · tsc exit 0 · production build exit 0 ·
Backend-Regressionen (route-order, m5-miniapps) 10/10 · compileall
sauber · Manifest-Check OK.
This commit is contained in:
Agent Zero
2026-09-13 19:45:18 +02:00
parent e7b746809b
commit 24423b6802
8 changed files with 898 additions and 4 deletions
+419
View File
@@ -0,0 +1,419 @@
/**
* Agent Memory page — persistent AI agent memories with semantic search
* (UI-Backlog module 8/16).
*
* Backend: /api/v1/agent-memory. Memories store facts, context, patterns
* and instructions for AI agents; search is pgvector-based semantic search.
* Permissions: agent_memory:read (list/search) / agent_memory:write
* (create, update, delete).
*
* NOTE (Phase Q): registered via the agent_memory PLUGIN MANIFEST —
* routes/index.tsx and Sidebar.tsx untouched.
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Brain,
Plus,
Pencil,
Trash2,
Search,
Bot,
AlertTriangle,
Inbox,
} from 'lucide-react';
import {
useAgentMemories,
useAgentMemorySearch,
useCreateAgentMemory,
useUpdateAgentMemory,
useDeleteAgentMemory,
type AgentMemory,
} from '@/api/agentMemory';
import { useAgents } from '@/api/automation';
import { usePermission } from '@/hooks/usePermission';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
const MEMORY_TYPES = ['fact', 'context', 'pattern', 'instruction'] as const;
type MemoryType = (typeof MEMORY_TYPES)[number];
const TYPE_BADGE: Record<string, string> = {
fact: 'bg-primary-100 text-primary-800',
context: 'bg-accent-100 text-accent-800',
pattern: 'bg-warning-100 text-warning-800',
instruction: 'bg-success-100 text-success-800',
};
function formatDateTime(iso: string | null): string {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function MemoryCard({
memory,
canWrite,
onEdit,
onDelete,
isMutating,
}: {
memory: AgentMemory;
canWrite: boolean;
onEdit: (m: AgentMemory) => void;
onDelete: (m: AgentMemory) => void;
isMutating: boolean;
}) {
const { t } = useTranslation();
return (
<Card className="p-4" data-testid={`memory-card-${memory.id}`}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span data-testid={`memory-type-${memory.id}`}>
<Badge className={TYPE_BADGE[memory.memory_type] ?? 'bg-secondary-100 text-secondary-800'}>
{memory.memory_type}
</Badge>
</span>
{memory.score !== undefined && memory.score > 0 && (
<Badge variant="secondary">{t('agentMemory.score', { score: (memory.score * 100).toFixed(0) })}</Badge>
)}
</div>
<p className="mt-2 text-sm text-secondary-900 dark:text-secondary-100 whitespace-pre-wrap" data-testid={`memory-content-${memory.id}`}>
{memory.content}
</p>
<div className="mt-2 text-xs text-secondary-500 dark:text-secondary-400">
{t('agentMemory.created')} {formatDateTime(memory.created_at)}
</div>
</div>
{canWrite && (
<div className="flex gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(memory)}
disabled={isMutating}
aria-label={t('agentMemory.edit')}
data-testid={`memory-edit-${memory.id}`}
>
<Pencil className="w-4 h-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(memory)}
disabled={isMutating}
aria-label={t('agentMemory.delete')}
data-testid={`memory-delete-${memory.id}`}
className="text-danger-600 hover:text-danger-700"
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
)}
</div>
</Card>
);
}
function MemoryFormDialog({
open,
agentId,
memory,
onClose,
onSubmit,
isSubmitting,
}: {
open: boolean;
agentId: string;
memory: AgentMemory | null;
onClose: () => void;
onSubmit: (payload: { agent_id: string; memory_type: string; content: string }) => void;
isSubmitting: boolean;
}) {
const { t } = useTranslation();
const [memoryType, setMemoryType] = useState<MemoryType>('fact');
const [content, setContent] = useState('');
React.useEffect(() => {
if (open) {
setMemoryType((memory?.memory_type as MemoryType) ?? 'fact');
setContent(memory?.content ?? '');
}
}, [open, memory]);
const valid = content.trim().length > 0;
const submit = () => {
if (!valid) return;
onSubmit({
agent_id: agentId,
memory_type: memoryType,
content: content.trim(),
});
};
return (
<Modal
open={open}
onClose={onClose}
title={memory ? t('agentMemory.editTitle') : t('agentMemory.createTitle')}
>
<div className="space-y-4">
<Select
label={t('agentMemory.memoryType')}
options={MEMORY_TYPES.map((mt) => ({ value: mt, label: t(`agentMemory.type_${mt}`) }))}
value={memoryType}
onChange={(e) => setMemoryType(e.target.value as MemoryType)}
required
/>
<div>
<label htmlFor="am-content" className="block text-sm font-medium text-secondary-700 mb-1">
{t('agentMemory.content')} *
</label>
<textarea
id="am-content"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-sm"
placeholder={t('agentMemory.contentPlaceholder')}
aria-label={t('agentMemory.content')}
data-testid="memory-content-input"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!valid || isSubmitting} data-testid="memory-form-submit">
{memory ? t('agentMemory.save') : t('agentMemory.createSubmit')}
</Button>
</div>
</div>
</Modal>
);
}
export function AgentMemoryPage() {
const { t } = useTranslation();
const [agentId, setAgentId] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [activeSearchQuery, setActiveSearchQuery] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [editMemory, setEditMemory] = useState<AgentMemory | null>(null);
const { data: agentsData, isLoading: agentsLoading } = useAgents();
const canRead = usePermission().hasPermission('agent_memory:read');
const canWrite = usePermission().hasPermission('agent_memory:write');
const listQuery = useAgentMemories({ agentId, pageSize: 50 });
const searchQuery_ = useAgentMemorySearch({
agentId,
query: activeSearchQuery,
limit: 20,
});
const createMut = useCreateAgentMemory();
const updateMut = useUpdateAgentMemory();
const deleteMut = useDeleteAgentMemory();
const isMutating = createMut.isPending || updateMut.isPending || deleteMut.isPending;
const isSearching = activeSearchQuery.length > 0;
const agentOptions = useMemo(
() =>
(agentsData ?? [])
.map((a) => ({ value: a.id, label: a.name }))
.sort((a, b) => a.label.localeCompare(b.label)),
[agentsData],
);
const handleCreate = (payload: { agent_id: string; memory_type: string; content: string }) => {
createMut.mutate(payload, {
onSuccess: () => setShowCreate(false),
});
};
const handleEdit = (payload: { agent_id: string; memory_type: string; content: string }) => {
if (!editMemory) return;
updateMut.mutate(
{
memoryId: editMemory.id,
payload: { content: payload.content, memory_type: payload.memory_type },
},
{
onSuccess: () => setEditMemory(null),
},
);
};
const handleDelete = (memory: AgentMemory) => {
if (window.confirm(t('agentMemory.deleteConfirm'))) {
deleteMut.mutate(memory.id);
}
};
const items = isSearching
? searchQuery_.data?.items ?? []
: listQuery.data?.items ?? [];
const isLoading = isSearching ? searchQuery_.isLoading : listQuery.isLoading;
const isError = isSearching ? searchQuery_.isError : listQuery.isError;
return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-4" data-testid="agent-memory-page">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Brain className="w-6 h-6 text-primary-600" aria-hidden="true" />
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{t('agentMemory.title')}
</h1>
</div>
{canWrite && agentId && (
<Button onClick={() => setShowCreate(true)} data-testid="memory-create-open">
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
{t('agentMemory.create')}
</Button>
)}
</div>
{/* Agent picker (required — backend filters by agent_id) */}
<div className="flex items-start gap-2">
<Bot className="w-4 h-4 text-secondary-500 mt-3" aria-hidden="true" />
<div className="flex-1">
<Select
label={t('agentMemory.agentLabel')}
options={agentOptions}
value={agentId}
onChange={(e) => {
setAgentId(e.target.value);
setActiveSearchQuery('');
}}
required
placeholder={t('agentMemory.agentPlaceholder')}
/>
</div>
</div>
{agentsLoading && (
<p className="text-xs text-secondary-500" data-testid="agent-memory-agents-loading">
{t('agentMemory.loadingAgents')}
</p>
)}
{/* Semantic search */}
{agentId && (
<div className="flex gap-2">
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400"
aria-hidden="true"
/>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') setActiveSearchQuery(searchQuery.trim());
}}
placeholder={t('agentMemory.searchPlaceholder')}
className="pl-9"
aria-label={t('agentMemory.searchPlaceholder')}
data-testid="memory-search"
/>
</div>
<Button
variant="secondary"
onClick={() => setActiveSearchQuery(searchQuery.trim())}
disabled={!searchQuery.trim()}
data-testid="memory-search-btn"
>
{t('agentMemory.search')}
</Button>
{isSearching && (
<Button
variant="ghost"
onClick={() => {
setActiveSearchQuery('');
setSearchQuery('');
}}
data-testid="memory-search-clear"
>
{t('agentMemory.clearSearch')}
</Button>
)}
</div>
)}
{isSearching && !isLoading && (
<p className="text-xs text-secondary-500" data-testid="memory-search-info">
{t('agentMemory.searchResults', { count: items.length })}
</p>
)}
{isLoading && (
<div className="flex items-center justify-center min-h-[30vh]" role="status" data-testid="agent-memory-loading">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500" aria-hidden="true" />
</div>
)}
{isError && (
<Card className="p-6 flex items-center gap-3 text-danger-600" data-testid="agent-memory-error">
<AlertTriangle className="w-5 h-5" aria-hidden="true" />
<span>{t('agentMemory.loadError')}</span>
</Card>
)}
{!isLoading && !isError && agentId && items.length === 0 && (
<Card className="p-8 flex flex-col items-center gap-3 text-secondary-500" data-testid="agent-memory-empty">
<Inbox className="w-10 h-10" aria-hidden="true" />
<p>{isSearching ? t('agentMemory.noSearchResults') : t('agentMemory.empty')}</p>
</Card>
)}
{!agentId && !agentsLoading && agentOptions.length > 0 && (
<Card className="p-8 flex flex-col items-center gap-3 text-secondary-500" data-testid="agent-memory-pick-agent">
<Bot className="w-10 h-10" aria-hidden="true" />
<p>{t('agentMemory.pickAgent')}</p>
</Card>
)}
<div className="space-y-3">
{items.map((memory) => (
<MemoryCard
key={memory.id}
memory={memory}
canWrite={canWrite}
onEdit={setEditMemory}
onDelete={handleDelete}
isMutating={isMutating}
/>
))}
</div>
<MemoryFormDialog
open={showCreate}
agentId={agentId}
memory={null}
onClose={() => setShowCreate(false)}
onSubmit={handleCreate}
isSubmitting={createMut.isPending}
/>
<MemoryFormDialog
open={!!editMemory}
agentId={agentId}
memory={editMemory}
onClose={() => setEditMemory(null)}
onSubmit={handleEdit}
isSubmitting={updateMut.isPending}
/>
</div>
);
}
export default AgentMemoryPage;