diff --git a/frontend/src/app/routes/settings.tsx b/frontend/src/app/routes/settings.tsx index 8c2778f45..2dfa7375e 100644 --- a/frontend/src/app/routes/settings.tsx +++ b/frontend/src/app/routes/settings.tsx @@ -8,6 +8,7 @@ import GeneralTab from '@/components/settings/general-tab' import { Button } from '@/components/ui/button' import { Icon } from '@/components/ui/icon' import DataControlTab from '@/components/settings/data-control-tab' +import PersonalizationTab from '@/components/settings/personalization-tab' import CreditUsage from '@/components/credit-usage' import SubscriptionTab from '@/components/settings/subscription-tab' import { Logo } from '@/components/logo' @@ -18,6 +19,7 @@ import { useIsSageTheme } from '@/hooks/use-is-sage-theme' enum SettingTab { GENERAL = 'general', + PERSONALIZATION = 'personalization', ACCOUNT = 'account', NOTIFICATIONS = 'notifications', CONNECTORS = 'connectors', @@ -37,6 +39,7 @@ const Settings = () => { const tabs = [ { key: SettingTab.GENERAL, label: t('settings.tabs.general') }, + { key: SettingTab.PERSONALIZATION, label: t('settings.tabs.personalization') }, { key: SettingTab.ACCOUNT, label: t('settings.tabs.account') }, // { key: SettingTab.NOTIFICATIONS, label: t('settings.tabs.notifications') }, // { key: SettingTab.CONNECTORS, label: t('settings.tabs.connectors') }, @@ -56,6 +59,8 @@ const Settings = () => { switch (activeTab) { case SettingTab.GENERAL: return + case SettingTab.PERSONALIZATION: + return case SettingTab.ACCOUNT: return case SettingTab.DATA_CONTROLS: diff --git a/frontend/src/components/agent-setting/tool-setting.tsx b/frontend/src/components/agent-setting/tool-setting.tsx index 3d760e4cc..87b21834a 100644 --- a/frontend/src/components/agent-setting/tool-setting.tsx +++ b/frontend/src/components/agent-setting/tool-setting.tsx @@ -40,6 +40,7 @@ enum TOOL { REVIEW_AGENT = 'Review Agent', CODEX = 'Codex', CLAUDE_CODE = 'Claude Code', + AGENTIC_MEMORY = 'Agentic Memory', WEB_SEARCH = 'Web Search', WEB_VISIT = 'Web Visit', IMAGE_SEARCH = 'Image Search', @@ -160,6 +161,9 @@ const ToolSetting = ({ className }: ToolSettingProps) => { case TOOL.CLAUDE_CODE: isActive = toolSettings?.claude_code || false break + case TOOL.AGENTIC_MEMORY: + isActive = toolSettings?.agentic_memory || false + break default: isActive = tool.isActive || false } @@ -222,6 +226,9 @@ const ToolSetting = ({ className }: ToolSettingProps) => { newSettings.claude_code = shouldEnableClaudeCode break } + case TOOL.AGENTIC_MEMORY: + newSettings.agentic_memory = checked + break case TOOL.WEB_SEARCH: newChatSettings.web_search = checked break diff --git a/frontend/src/components/settings/memory-table.tsx b/frontend/src/components/settings/memory-table.tsx new file mode 100644 index 000000000..e3e1ba7f2 --- /dev/null +++ b/frontend/src/components/settings/memory-table.tsx @@ -0,0 +1,667 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' + +import { memoryService } from '@/services/memory.service' +import type { Memory } from '@/typings/memory' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Textarea } from '../ui/textarea' +import { Badge } from '../ui/badge' +import { Icon } from '../ui/icon' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '../ui/table' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '../ui/select' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from '../ui/collapsible' +import { toast } from 'sonner' + +dayjs.extend(utc) + +const PER_PAGE = 10 + +const MemoryTable = () => { + const { t } = useTranslation() + + // Data + const [memories, setMemories] = useState([]) + const [loading, setLoading] = useState(false) + const [allTopics, setAllTopics] = useState([]) + + // Pagination + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + + // Search / Filter / Sort + const [search, setSearch] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [selectedTopic, setSelectedTopic] = useState('') + const [sortBy, setSortBy] = useState< + 'updated_at' | 'memory' | 'topics_count' + >('updated_at') + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') + + // Expandable rows + const [expandedRows, setExpandedRows] = useState>(new Set()) + + // Dialog states + const [formDialogOpen, setFormDialogOpen] = useState(false) + const [editingMemory, setEditingMemory] = useState(null) + const [formMemory, setFormMemory] = useState('') + const [formTopics, setFormTopics] = useState('') + const [formSaving, setFormSaving] = useState(false) + + // Delete state + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [pendingDeleteId, setPendingDeleteId] = useState(null) + + const totalPages = useMemo( + () => Math.max(1, Math.ceil(total / PER_PAGE)), + [total] + ) + + // Fetch memories + const fetchMemories = useCallback(async () => { + setLoading(true) + try { + const result = await memoryService.listMemories({ + page, + perPage: PER_PAGE, + search: debouncedSearch || undefined, + topics: selectedTopic || undefined, + sortBy, + sortOrder + }) + setMemories(result.memories) + setTotal(result.total) + } catch { + toast.error(t('errors.generic')) + } finally { + setLoading(false) + } + }, [page, debouncedSearch, selectedTopic, sortBy, sortOrder, t]) + + // Debounce search + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search) + setPage(1) + }, 300) + return () => clearTimeout(timer) + }, [search]) + + // Fetch on param change + useEffect(() => { + fetchMemories() + }, [fetchMemories]) + + // Load topics for filter + useEffect(() => { + memoryService.getTopics().then(setAllTopics).catch(() => {}) + }, []) + + // Refresh topics after mutations + const refreshTopics = () => { + memoryService.getTopics().then(setAllTopics).catch(() => {}) + } + + // Sort handler + const handleSort = (column: 'updated_at' | 'memory' | 'topics_count') => { + if (sortBy === column) { + setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy(column) + setSortOrder('desc') + } + setPage(1) + } + + // Expand/collapse row + const toggleRow = (memoryId: string) => { + setExpandedRows((prev) => { + const next = new Set(prev) + if (next.has(memoryId)) next.delete(memoryId) + else next.add(memoryId) + return next + }) + } + + // Open add dialog + const openAddDialog = () => { + setEditingMemory(null) + setFormMemory('') + setFormTopics('') + setFormDialogOpen(true) + } + + // Open edit dialog + const openEditDialog = (memory: Memory) => { + setEditingMemory(memory) + setFormMemory(memory.memory) + setFormTopics(memory.topics?.join(', ') ?? '') + setFormDialogOpen(true) + } + + // Save (add or edit) + const handleSave = async () => { + if (!formMemory.trim()) return + setFormSaving(true) + + const topicsList = formTopics + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + + try { + if (editingMemory) { + await memoryService.updateMemory(editingMemory.memory_id, { + memory: formMemory.trim(), + topics: topicsList + }) + } else { + await memoryService.createMemory({ + memory: formMemory.trim(), + topics: topicsList + }) + } + setFormDialogOpen(false) + fetchMemories() + refreshTopics() + } catch { + toast.error(t('errors.generic')) + } finally { + setFormSaving(false) + } + } + + // Delete + const handleDelete = async () => { + if (!pendingDeleteId) return + try { + await memoryService.deleteMemory(pendingDeleteId) + setDeleteDialogOpen(false) + setPendingDeleteId(null) + fetchMemories() + refreshTopics() + } catch { + toast.error(t('errors.generic')) + } + } + + // Pagination + const goPrev = () => setPage((p) => Math.max(1, p - 1)) + const goNext = () => setPage((p) => Math.min(totalPages, p + 1)) + + const SortIndicator = ({ + column + }: { + column: 'updated_at' | 'memory' | 'topics_count' + }) => + sortBy === column ? ( + + {sortOrder === 'asc' ? '↑' : '↓'} + + ) : null + + return ( +
+ {/* Header */} +
+

+ {t('settings.personalization.memoriesTitle')} +

+

+ {t('settings.personalization.memoriesDescription')} +

+
+ + {/* Toolbar */} +
+ setSearch(e.target.value)} + /> + + +
+ + {/* Table */} +
+ + + + + + + + + + + + + + + + + + {loading && ( + + + {t('common.loading')} + + + )} + {!loading && memories.length === 0 && ( + + + {debouncedSearch || selectedTopic + ? t('settings.personalization.noMatchingMemories') + : t('settings.personalization.noMemories')} + + + )} + {!loading && + memories.map((mem) => ( + + toggleRow(mem.memory_id) + } + asChild + > + <> + + + + + + + + + {mem.memory} + + + +
+ {mem.topics?.map( + (topic) => ( + + {topic} + + ) + )} +
+
+ + {mem.updated_at + ? dayjs + .unix( + mem.updated_at + ) + .format( + 'DD MMM YYYY' + ) + : '—'} + + +
+ + +
+
+
+ +
+ + + + + + ))} + +
+
+

+ {mem.memory} +

+ {mem.topics && + mem.topics.length > + 0 && ( +
+ {mem.topics.map( + ( + topic + ) => ( + + { + topic + } + + ) + )} +
+ )} + {mem.created_at && ( +
+ + {t( + 'settings.personalization.expandCreatedAt' + )} + :{' '} + {dayjs + .unix( + mem.created_at + ) + .format( + 'DD MMM YYYY, HH:mm' + )} + +
+ )} +
+
+
+ + {/* Pagination */} + {total > PER_PAGE && ( +
+ + + {page < totalPages && ( + + )} + +
+ )} + + {/* Add/Edit Dialog */} + + + + + {editingMemory + ? t('settings.personalization.editMemory') + : t('settings.personalization.addMemory')} + + +
+
+ +