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
@@ -0,0 +1,260 @@
/**
* AgentMemory page tests — persistent agent memories (module 8/16).
*
* Covers: agent picker requirement, pick-agent prompt, memory cards with
* type badges and search score, semantic search flow with clear, create
* flow, edit flow prefilled, delete with confirmation, permission gating
* (agent_memory:write), empty and error states.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { AgentMemoryPage } from '@/pages/AgentMemory';
import type { AgentMemory } from '@/api/agentMemory';
import type { AgentDefinition } from '@/types/automation';
const createMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({});
return Promise.resolve({});
});
const updateMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({});
return Promise.resolve({});
});
const deleteMut = vi.fn().mockResolvedValue({});
const AGENT: AgentDefinition = {
id: '11111111-1111-1111-1111-111111111111',
name: 'Test Agent',
description: '',
model: 'gpt-4o-mini',
heartbeat_interval: 0,
mode: 'reactive',
max_executions_per_hour: 0,
max_duration: 0,
budget_limit: 0,
active: true,
created_at: '2026-09-01T00:00:00Z',
updated_at: '2026-09-01T00:00:00Z',
};
const makeMemory = (overrides: Partial<AgentMemory> = {}): AgentMemory => ({
id: 'mem-1',
agent_id: AGENT.id,
memory_type: 'fact',
content: 'Kunde bevorzugt E-Mail',
owner_id: null,
created_at: '2026-09-01T10:00:00Z',
updated_at: null,
...overrides,
});
let mockItems: AgentMemory[] = [];
let mockSearchItems: AgentMemory[] = [];
let mockCanWrite = true;
let mockError = false;
vi.mock('@/api/agentMemory', () => ({
useAgentMemories: () => ({
data: { items: mockItems, total: mockItems.length },
isLoading: false,
isError: mockError,
isFetching: false,
refetch: vi.fn(),
}),
useAgentMemorySearch: () => ({
data: { items: mockSearchItems, total: mockSearchItems.length },
isLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(),
}),
useCreateAgentMemory: () => ({
mutate: createMut,
isPending: false,
}),
useUpdateAgentMemory: () => ({
mutate: updateMut,
isPending: false,
}),
useDeleteAgentMemory: () => ({
mutate: deleteMut,
isPending: false,
}),
}));
vi.mock('@/api/automation', () => ({
useAgents: () => ({
data: [AGENT],
isLoading: false,
isError: false,
}),
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) =>
mockCanWrite || perm !== 'agent_memory:write',
}),
}));
function renderPage() {
return render(
<MemoryRouter>
<AgentMemoryPage />
</MemoryRouter>,
);
}
function pickAgent() {
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: AGENT.id } });
}
beforeEach(() => {
vi.clearAllMocks();
mockItems = [];
mockSearchItems = [];
mockCanWrite = true;
mockError = false;
});
describe('AgentMemoryPage', () => {
it('renders the page with title and agent picker', () => {
renderPage();
expect(screen.getByTestId('agent-memory-page')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('shows the pick-agent prompt before an agent is selected', () => {
renderPage();
expect(screen.getByTestId('agent-memory-pick-agent')).toBeInTheDocument();
// list is not loaded without an agent — no empty state yet
expect(screen.queryByTestId('agent-memory-empty')).not.toBeInTheDocument();
});
it('shows empty state after picking an agent with no memories', () => {
renderPage();
pickAgent();
expect(screen.getByTestId('agent-memory-empty')).toBeInTheDocument();
});
it('shows error state on load failure', () => {
mockError = true;
renderPage();
pickAgent();
expect(screen.getByTestId('agent-memory-error')).toBeInTheDocument();
});
it('renders memory cards with type badge', () => {
mockItems = [makeMemory()];
renderPage();
pickAgent();
expect(screen.getByTestId('memory-card-mem-1')).toBeInTheDocument();
expect(screen.getByTestId('memory-type-mem-1')).toHaveTextContent('fact');
expect(screen.getByTestId('memory-content-mem-1')).toHaveTextContent('Kunde bevorzugt E-Mail');
});
it('hides create and action buttons without agent_memory:write', () => {
mockItems = [makeMemory()];
mockCanWrite = false;
renderPage();
pickAgent();
expect(screen.queryByTestId('memory-create-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('memory-edit-mem-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('memory-delete-mem-1')).not.toBeInTheDocument();
});
it('creates a memory via the dialog after picking an agent', async () => {
renderPage();
pickAgent();
fireEvent.click(screen.getByTestId('memory-create-open'));
expect(screen.getByTestId('memory-form-submit')).toBeInTheDocument();
const contentArea = screen.getByTestId('memory-content-input') as HTMLTextAreaElement;
fireEvent.change(contentArea, { target: { value: 'Neues Wissen' } });
fireEvent.click(screen.getByTestId('memory-form-submit'));
await waitFor(() => {
expect(createMut).toHaveBeenCalledWith(
expect.objectContaining({
agent_id: AGENT.id,
memory_type: 'fact',
content: 'Neues Wissen',
}),
expect.anything(),
);
});
});
it('opens the edit dialog prefilled and submits the update', async () => {
mockItems = [makeMemory({ memory_type: 'instruction' })];
renderPage();
pickAgent();
fireEvent.click(screen.getByTestId('memory-edit-mem-1'));
const contentArea = screen.getByTestId('memory-content-input') as HTMLTextAreaElement;
expect(contentArea).toHaveValue('Kunde bevorzugt E-Mail');
fireEvent.change(contentArea, { target: { value: 'Geaendertes Wissen' } });
fireEvent.click(screen.getByTestId('memory-form-submit'));
await waitFor(() => {
expect(updateMut).toHaveBeenCalledWith(
{
memoryId: 'mem-1',
payload: expect.objectContaining({
content: 'Geaendertes Wissen',
memory_type: 'instruction',
}),
},
expect.anything(),
);
});
});
it('runs a semantic search and shows scored results, then clears', async () => {
mockItems = [makeMemory()];
mockSearchItems = [makeMemory({ id: 'mem-2', score: 0.87 })];
renderPage();
pickAgent();
const searchInput = screen.getByTestId('memory-search');
fireEvent.change(searchInput, { target: { value: 'E-Mail' } });
fireEvent.click(screen.getByTestId('memory-search-btn'));
// Scored search result is rendered
expect(await screen.findByTestId('memory-card-mem-2')).toBeInTheDocument();
expect(screen.getByText(/87%/)).toBeInTheDocument();
expect(screen.getByTestId('memory-search-clear')).toBeInTheDocument();
// Clear returns to list mode
fireEvent.click(screen.getByTestId('memory-search-clear'));
await waitFor(() => {
expect(screen.getByTestId('memory-card-mem-1')).toBeInTheDocument();
expect(screen.queryByTestId('memory-card-mem-2')).not.toBeInTheDocument();
});
});
it('deletes a memory after confirmation', async () => {
mockItems = [makeMemory()];
window.confirm = vi.fn(() => true);
renderPage();
pickAgent();
fireEvent.click(screen.getByTestId('memory-delete-mem-1'));
await waitFor(() => {
expect(deleteMut).toHaveBeenCalledWith('mem-1');
});
});
it('does not delete when the confirmation is rejected', () => {
mockItems = [makeMemory()];
window.confirm = vi.fn(() => false);
renderPage();
pickAgent();
fireEvent.click(screen.getByTestId('memory-delete-mem-1'));
expect(deleteMut).not.toHaveBeenCalled();
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Agent Memory API client — persistent agent memories with pgvector
* semantic search.
*
* Backend: /api/v1/agent-memory (create, list, search, update, delete).
* Memories store facts, context, patterns and instructions for AI agents.
* Permissions: agent_memory:read (list/search) / agent_memory:write
* (create, update, delete).
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
export interface AgentMemory {
id: string;
agent_id: string;
memory_type: string;
content: string;
owner_id: string | null;
created_at: string | null;
updated_at: string | null;
/** Similarity score — only set by semantic search results. */
score?: number;
}
export interface AgentMemoryListResponse {
items: AgentMemory[];
total: number;
}
export interface AgentMemoryCreatePayload {
agent_id: string;
memory_type: string;
content: string;
}
export interface AgentMemoryUpdatePayload {
content?: string;
memory_type?: string;
}
export interface AgentMemoryListParams {
agentId: string;
memoryType?: string;
page?: number;
pageSize?: number;
}
export interface AgentMemorySearchParams {
agentId: string;
query: string;
memoryType?: string;
limit?: number;
minScore?: number;
}
// ─── Query hooks ─────────────────────────────────────────────
export function useAgentMemories(params: AgentMemoryListParams) {
const searchParams = new URLSearchParams();
searchParams.set('agent_id', params.agentId);
if (params.memoryType) searchParams.set('memory_type', params.memoryType);
if (params.page) searchParams.set('page', String(params.page));
if (params.pageSize) searchParams.set('page_size', String(params.pageSize));
return useQuery<AgentMemoryListResponse>({
queryKey: ['agent-memories', params.agentId, params.memoryType, params.page, params.pageSize],
queryFn: () => apiGet<AgentMemoryListResponse>(`/agent-memory?${searchParams.toString()}`),
enabled: !!params.agentId,
});
}
export function useAgentMemorySearch(params: AgentMemorySearchParams) {
const searchParams = new URLSearchParams();
searchParams.set('agent_id', params.agentId);
searchParams.set('query', params.query);
if (params.memoryType) searchParams.set('memory_type', params.memoryType);
if (params.limit) searchParams.set('limit', String(params.limit));
if (params.minScore !== undefined) searchParams.set('min_score', String(params.minScore));
return useQuery<AgentMemoryListResponse>({
queryKey: ['agent-memories', 'search', params.agentId, params.query, params.memoryType],
queryFn: () => apiGet<AgentMemoryListResponse>(`/agent-memory/search?${searchParams.toString()}`),
enabled: !!params.agentId && !!params.query,
});
}
// ─── Mutation hooks ──────────────────────────────────────────
function useInvalidateMemories() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['agent-memories'] });
};
}
export function useCreateAgentMemory() {
const invalidate = useInvalidateMemories();
return useMutation<AgentMemory, Error, AgentMemoryCreatePayload>({
mutationFn: (data) => apiPost<AgentMemory>('/agent-memory', data),
onSuccess: invalidate,
});
}
export function useUpdateAgentMemory() {
const invalidate = useInvalidateMemories();
return useMutation<
AgentMemory,
Error,
{ memoryId: string; payload: AgentMemoryUpdatePayload }
>({
mutationFn: ({ memoryId, payload }) =>
apiPatch<AgentMemory>(`/agent-memory/${memoryId}`, payload),
onSuccess: invalidate,
});
}
export function useDeleteAgentMemory() {
const invalidate = useInvalidateMemories();
return useMutation<void, Error, string>({
mutationFn: (memoryId) => apiDelete(`/agent-memory/${memoryId}`),
onSuccess: invalidate,
});
}
+2 -1
View File
@@ -3,7 +3,7 @@ import clsx from 'clsx';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useUIStore } from '@/store/uiStore';
import { ArrowLeft, ArrowRightLeft, CheckCircle2, ChevronRight, FileText, Home, Settings, Users } from 'lucide-react';
import { ArrowLeft, ArrowRightLeft, Brain, CheckCircle2, ChevronRight, FileText, Home, Settings, Store, Tags, Users } from 'lucide-react';
import { usePluginStore } from '@/store/pluginStore';
// Curated icon map — avoids `import * as LucideIcons` which loads ALL icons and causes OOM in tests
import {
@@ -26,6 +26,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
MoreVertical, RefreshCw, Download, Upload, Eye, EyeOff, Lock,
Unlock, Clock, AlertCircle, CheckCircle, XCircle, Info, Loader2,
Inbox, Send, ChevronRight, BookOpen, Lightbulb,
Brain, Store, Tags, ArrowRightLeft,
};
import { useMenuOrder } from '@/api/users';
import { usePermission } from '@/hooks/usePermission';
@@ -31,6 +31,7 @@ export const PLUGIN_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
'@/components/dashboard/WikiRecentWidget': () => import('@/components/dashboard/WikiRecentWidget').then((m) => ({ default: m.WikiRecentWidget })),
'@/pages/AIAssistant': () => import('@/pages/AIAssistant').then(normalizeModule),
'@/pages/AISettings': () => import('@/pages/AISettings').then((m) => ({ default: m.AISettingsPage })),
'@/pages/AgentMemory': () => import('@/pages/AgentMemory').then(normalizeModule),
'@/pages/AutomationSettings': () => import('@/pages/AutomationSettings').then((m) => ({ default: m.AutomationSettingsPage })),
'@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule),
'@/pages/CalendarKanban': () => import('@/pages/CalendarKanban').then(normalizeModule),
+33 -1
View File
@@ -25,7 +25,8 @@
"approvals": "Freigaben",
"delegations": "Delegationen",
"marketplace": "Marketplace",
"skills": "Skills"
"skills": "Skills",
"agentMemory": "Agent Memory"
},
"auth": {
"login": "Anmelden",
@@ -1788,5 +1789,36 @@
"deleteConfirm": "Skill '{{name}}' wirklich loeschen?",
"empty": "Keine Skills vorhanden.",
"loadError": "Skills konnten nicht geladen werden."
},
"agentMemory": {
"title": "Agent Memory",
"agentLabel": "Agent",
"agentPlaceholder": "Agent auswaehlen",
"loadingAgents": "Lade Agenten...",
"pickAgent": "Bitte einen Agenten auswaehlen, um dessen Memories zu sehen.",
"create": "Memory erstellen",
"createTitle": "Neues Memory",
"createSubmit": "Erstellen",
"editTitle": "Memory bearbeiten",
"save": "Speichern",
"memoryType": "Typ",
"type_fact": "Fakt",
"type_context": "Kontext",
"type_pattern": "Muster",
"type_instruction": "Anweisung",
"content": "Inhalt",
"contentPlaceholder": "Was soll der Agent sich merken?",
"search": "Suchen",
"searchPlaceholder": "Semantische Suche... (Enter)",
"clearSearch": "Zuruecksetzen",
"searchResults": "{{count}} Treffer (semantisch)",
"noSearchResults": "Keine Treffer fuer diese Suche.",
"score": "Relevanz: {{score}}%",
"created": "Erstellt:",
"edit": "Bearbeiten",
"delete": "Loeschen",
"deleteConfirm": "Dieses Memory wirklich loeschen?",
"empty": "Keine Memories fuer diesen Agenten.",
"loadError": "Memories konnten nicht geladen werden."
}
}
+33 -1
View File
@@ -25,7 +25,8 @@
"approvals": "Approvals",
"delegations": "Delegations",
"marketplace": "Marketplace",
"skills": "Skills"
"skills": "Skills",
"agentMemory": "Agent Memory"
},
"auth": {
"login": "Sign In",
@@ -1788,5 +1789,36 @@
"deleteConfirm": "Really delete skill '{{name}}'?",
"empty": "No skills yet.",
"loadError": "Failed to load skills."
},
"agentMemory": {
"title": "Agent Memory",
"agentLabel": "Agent",
"agentPlaceholder": "Select an agent",
"loadingAgents": "Loading agents...",
"pickAgent": "Pick an agent to see its memories.",
"create": "Create memory",
"createTitle": "New memory",
"createSubmit": "Create",
"editTitle": "Edit memory",
"save": "Save",
"memoryType": "Type",
"type_fact": "Fact",
"type_context": "Context",
"type_pattern": "Pattern",
"type_instruction": "Instruction",
"content": "Content",
"contentPlaceholder": "What should the agent remember?",
"search": "Search",
"searchPlaceholder": "Semantic search... (Enter)",
"clearSearch": "Clear",
"searchResults": "{{count}} matches (semantic)",
"noSearchResults": "No matches for this search.",
"score": "Relevance: {{score}}%",
"created": "Created:",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Really delete this memory?",
"empty": "No memories for this agent.",
"loadError": "Failed to load memories."
}
}
+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;