diff --git a/app/plugins/builtins/agent_memory/plugin.py b/app/plugins/builtins/agent_memory/plugin.py index 3b8ff21..f3ee939 100644 --- a/app/plugins/builtins/agent_memory/plugin.py +++ b/app/plugins/builtins/agent_memory/plugin.py @@ -3,7 +3,12 @@ from __future__ import annotations from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import ( + FrontendMenuItem, + FrontendPageRoute, + PluginManifest, + PluginRouteDef, +) class AgentMemoryPlugin(BasePlugin): @@ -28,6 +33,26 @@ class AgentMemoryPlugin(BasePlugin): "agent_memory:read", "agent_memory:write", ], + # UI-Backlog Modul 8 (2026-09-13): agent memory page, registered + # via the manifest (Phase Q pattern). + menu_items=[ + FrontendMenuItem( + label_key="nav.agentMemory", + label="Agent Memory", + path="/agent-memory", + icon="Brain", + order=86, + permission="agent_memory:read", + ), + ], + page_routes=[ + FrontendPageRoute( + path="/agent-memory", + component="@/pages/AgentMemory", + protected=True, + permission="agent_memory:read", + ), + ], is_core=True, author="LeoCRM Team", min_app_version="1.0.0", diff --git a/frontend/src/__tests__/pages/AgentMemory.test.tsx b/frontend/src/__tests__/pages/AgentMemory.test.tsx new file mode 100644 index 0000000..e406630 --- /dev/null +++ b/frontend/src/__tests__/pages/AgentMemory.test.tsx @@ -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 => ({ + 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( + + + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/api/agentMemory.ts b/frontend/src/api/agentMemory.ts new file mode 100644 index 0000000..a53c0b9 --- /dev/null +++ b/frontend/src/api/agentMemory.ts @@ -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({ + queryKey: ['agent-memories', params.agentId, params.memoryType, params.page, params.pageSize], + queryFn: () => apiGet(`/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({ + queryKey: ['agent-memories', 'search', params.agentId, params.query, params.memoryType], + queryFn: () => apiGet(`/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({ + mutationFn: (data) => apiPost('/agent-memory', data), + onSuccess: invalidate, + }); +} + +export function useUpdateAgentMemory() { + const invalidate = useInvalidateMemories(); + return useMutation< + AgentMemory, + Error, + { memoryId: string; payload: AgentMemoryUpdatePayload } + >({ + mutationFn: ({ memoryId, payload }) => + apiPatch(`/agent-memory/${memoryId}`, payload), + onSuccess: invalidate, + }); +} + +export function useDeleteAgentMemory() { + const invalidate = useInvalidateMemories(); + return useMutation({ + mutationFn: (memoryId) => apiDelete(`/agent-memory/${memoryId}`), + onSuccess: invalidate, + }); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index d08a6a6..3976e1c 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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> = { 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'; diff --git a/frontend/src/generated/pluginComponents.generated.ts b/frontend/src/generated/pluginComponents.generated.ts index 0e3a599..91dad00 100644 --- a/frontend/src/generated/pluginComponents.generated.ts +++ b/frontend/src/generated/pluginComponents.generated.ts @@ -31,6 +31,7 @@ export const PLUGIN_COMPONENT_MAP: Record = { '@/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), diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index d4cd691..51a98b3 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 81484f9..d612109 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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." } } diff --git a/frontend/src/pages/AgentMemory.tsx b/frontend/src/pages/AgentMemory.tsx new file mode 100644 index 0000000..afba1d8 --- /dev/null +++ b/frontend/src/pages/AgentMemory.tsx @@ -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 = { + 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 ( + +
+
+
+ + + {memory.memory_type} + + + {memory.score !== undefined && memory.score > 0 && ( + {t('agentMemory.score', { score: (memory.score * 100).toFixed(0) })} + )} +
+

+ {memory.content} +

+
+ {t('agentMemory.created')} {formatDateTime(memory.created_at)} +
+
+ {canWrite && ( +
+ + +
+ )} +
+
+ ); +} + +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('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 ( + +
+