From 350d9bb7b97c20a950f8d2551e48bb7bef09d110 Mon Sep 17 00:00:00 2001 From: Rafael Uzarowski Date: Fri, 25 Jul 2025 18:15:43 +0200 Subject: [PATCH 1/3] feat: memory dashboard first version --- python/api/memory_dashboard.py | 128 ++ python/api/memory_delete.py | 55 + python/helpers/settings.py | 10 + .../settings/memory/memory-dashboard-store.js | 254 ++++ .../settings/memory/memory-dashboard.html | 1171 +++++++++++++++++ webui/js/settings.js | 2 + 6 files changed, 1620 insertions(+) create mode 100644 python/api/memory_dashboard.py create mode 100644 python/api/memory_delete.py create mode 100644 webui/components/settings/memory/memory-dashboard-store.js create mode 100644 webui/components/settings/memory/memory-dashboard.html diff --git a/python/api/memory_dashboard.py b/python/api/memory_dashboard.py new file mode 100644 index 000000000..88579f09e --- /dev/null +++ b/python/api/memory_dashboard.py @@ -0,0 +1,128 @@ +from python.helpers.api import ApiHandler, Request, Response +from python.helpers.memory import Memory + + +class MemoryDashboard(ApiHandler): + + async def process(self, input: dict, request: Request) -> dict | Response: + try: + # Get filter parameters + area_filter = input.get("area", "") # Filter by memory area (MAIN, FRAGMENTS, SOLUTIONS, INSTRUMENTS) + search_query = input.get("search", "") # Full-text search query + limit = input.get("limit", 100) # Number of results to return + + # Get context and agent + ctxid = input.get("context", "") + context = self.get_context(ctxid) + + # Check if memory is already initialized to avoid triggering preload + memory_subdir = context.agent0.config.memory_subdir or "default" + if Memory.index.get(memory_subdir) is None: + # Memory not initialized yet, return empty results + return { + "success": True, + "memories": [], + "total_count": 0, + "knowledge_count": 0, + "conversation_count": 0, + "areas_count": {}, + "search_query": search_query, + "area_filter": area_filter, + "message": "Memory database not yet initialized. Use the agent first to initialize memory." + } + + # Get already initialized memory instance (no initialization triggered) + db = Memory( + agent=context.agent0, + db=Memory.index[memory_subdir], + memory_subdir=memory_subdir, + ) + + memories = [] + + if search_query: + # If search query provided, use similarity search + threshold = 0.6 # Lower threshold for broader search in dashboard + filter_expr = f"area == '{area_filter}'" if area_filter else "" + + docs = await db.search_similarity_threshold( + query=search_query, + limit=limit, + threshold=threshold, + filter=filter_expr + ) + memories = docs + else: + # If no search query, get all memories from specified area(s) + all_docs = db.db.get_all_docs() + + for doc_id, doc in all_docs.items(): + # Apply area filter if specified + if area_filter and doc.metadata.get("area", "") != area_filter: + continue + + memories.append(doc) + + # Apply limit + if len(memories) >= limit: + break + + # Format memories for the dashboard + formatted_memories = [] + for memory in memories: + metadata = memory.metadata + + # Extract key information + memory_data = { + "id": metadata.get("id", "unknown"), + "area": metadata.get("area", "unknown"), + "timestamp": metadata.get("timestamp", "unknown"), + "content_preview": memory.page_content[:200] + ("..." if len(memory.page_content) > 200 else ""), + "content_full": memory.page_content, + "knowledge_source": metadata.get("knowledge_source", False), + "source_file": metadata.get("source_file", ""), + "file_type": metadata.get("file_type", ""), + "consolidation_action": metadata.get("consolidation_action", ""), + "tags": metadata.get("tags", []), + "metadata": metadata # Include full metadata for advanced users + } + + formatted_memories.append(memory_data) + + # Sort by timestamp (newest first) - handle "unknown" timestamps + def get_sort_key(memory): + timestamp = memory["timestamp"] + if timestamp == "unknown" or not timestamp: + return "0000-00-00 00:00:00" # Put unknown timestamps at the end + return timestamp + + formatted_memories.sort(key=get_sort_key, reverse=True) + + # Get summary statistics + total_memories = len(formatted_memories) + knowledge_count = sum(1 for m in formatted_memories if m["knowledge_source"]) + conversation_count = total_memories - knowledge_count + + areas_count: dict[str, int] = {} + for memory in formatted_memories: + area = memory["area"] + areas_count[area] = areas_count.get(area, 0) + 1 + + return { + "success": True, + "memories": formatted_memories, + "total_count": total_memories, + "knowledge_count": knowledge_count, + "conversation_count": conversation_count, + "areas_count": areas_count, + "search_query": search_query, + "area_filter": area_filter + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "memories": [], + "total_count": 0 + } diff --git a/python/api/memory_delete.py b/python/api/memory_delete.py new file mode 100644 index 000000000..c35f7a965 --- /dev/null +++ b/python/api/memory_delete.py @@ -0,0 +1,55 @@ +from python.helpers.api import ApiHandler, Request, Response +from python.helpers.memory import Memory + + +class MemoryDelete(ApiHandler): + + async def process(self, input: dict, request: Request) -> dict | Response: + try: + # Get memory ID to delete + memory_id = input.get("memory_id", "") + if not memory_id: + return { + "success": False, + "error": "Memory ID is required" + } + + # Get context and agent + ctxid = input.get("context", "") + context = self.get_context(ctxid) + + # Check if memory is initialized to avoid triggering preload + memory_subdir = context.agent0.config.memory_subdir or "default" + if Memory.index.get(memory_subdir) is None: + return { + "success": False, + "error": "Memory database not initialized" + } + + # Get already initialized memory instance (no initialization triggered) + db = Memory( + agent=context.agent0, + db=Memory.index[memory_subdir], + memory_subdir=memory_subdir, + ) + + # Delete the memory by ID + deleted_docs = await db.delete_documents_by_ids([memory_id]) + + if deleted_docs: + return { + "success": True, + "message": f"Memory {memory_id} deleted successfully", + "deleted_count": len(deleted_docs) + } + else: + return { + "success": False, + "error": f"Memory {memory_id} not found or already deleted" + } + + except Exception as e: + return { + "success": False, + "error": str(e) + } diff --git a/python/helpers/settings.py b/python/helpers/settings.py index db4d9b780..f4c4e64ae 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -638,6 +638,16 @@ def convert_out(settings: Settings) -> SettingsOutput: } ) + memory_fields.append( + { + "id": "memory_dashboard", + "title": "Memory Dashboard", + "description": "View and explore all stored memories in a table format with filtering and search capabilities.", + "type": "button", + "value": "Open Dashboard", + } + ) + memory_fields.append( { "id": "memory_recall_enabled", diff --git a/webui/components/settings/memory/memory-dashboard-store.js b/webui/components/settings/memory/memory-dashboard-store.js new file mode 100644 index 000000000..1712b3c78 --- /dev/null +++ b/webui/components/settings/memory/memory-dashboard-store.js @@ -0,0 +1,254 @@ +import { createStore } from "/js/AlpineStore.js"; +import { getContext } from "/index.js"; +import * as API from "/js/api.js"; + +const model = { + memories: [], + loading: true, + error: null, + + // Filter states + areaFilter: "", + searchQuery: "", + currentPage: 1, + itemsPerPage: 10, + + // Statistics + totalCount: 0, + knowledgeCount: 0, + conversationCount: 0, + areasCount: {}, + + // UI state + selectedMemory: null, + showDetailModal: false, + message: null, // For displaying initialization messages + + async initialize() { + await this.loadMemories(); + }, + + async loadMemories() { + this.loading = true; + this.error = null; + this.message = null; + + try { + const response = await API.callJsonApi("memory_dashboard", { + context: getContext(), + area: this.areaFilter, + search: this.searchQuery, + limit: 500 // Get more for client-side pagination + }); + + if (response.success) { + this.memories = response.memories || []; // Already sorted by API + this.totalCount = response.total_count || 0; + this.knowledgeCount = response.knowledge_count || 0; + this.conversationCount = response.conversation_count || 0; + this.areasCount = response.areas_count || {}; + this.message = response.message || null; // Handle initialization messages + this.currentPage = 1; // Reset to first page when loading new data + } else { + this.error = response.error || "Failed to load memories"; + this.memories = []; + this.message = null; + } + } catch (error) { + this.error = error.message || "Failed to load memories"; + this.memories = []; + this.message = null; + console.error("Memory dashboard error:", error); + } finally { + this.loading = false; + } + }, + + async applyFilters() { + await this.loadMemories(); + }, + + clearFilters() { + this.areaFilter = ""; + this.searchQuery = ""; + this.applyFilters(); + }, + + // Pagination + get totalPages() { + return Math.ceil(this.memories.length / this.itemsPerPage); + }, + + get paginatedMemories() { + const startIndex = (this.currentPage - 1) * this.itemsPerPage; + const endIndex = startIndex + this.itemsPerPage; + return this.memories.slice(startIndex, endIndex); + }, + + goToPage(page) { + if (page >= 1 && page <= this.totalPages) { + this.currentPage = page; + } + }, + + nextPage() { + if (this.currentPage < this.totalPages) { + this.currentPage++; + } + }, + + prevPage() { + if (this.currentPage > 1) { + this.currentPage--; + } + }, + + // Memory details + showMemoryDetails(memory) { + this.selectedMemory = memory; + this.showDetailModal = true; + }, + + closeDetailModal() { + this.showDetailModal = false; + this.selectedMemory = null; + }, + + + + // Utility methods + formatTimestamp(timestamp, compact = false) { + if (!timestamp || timestamp === "unknown") return "Unknown"; + try { + // Parse timestamp - assume UTC if no timezone specified + let date; + if (timestamp.includes('T') || timestamp.includes('Z') || timestamp.includes('+')) { + // ISO format with timezone info + date = new Date(timestamp); + } else { + // Assume UTC for plain format "YYYY-MM-DD HH:MM:SS" + date = new Date(timestamp + ' UTC'); + } + + // Convert to local time and format + if (compact) { + // Compact format for table view + return date.toLocaleString(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + } else { + // Full format for detail view + return date.toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + } + } catch { + return timestamp; + } + }, + + formatTags(tags) { + if (!Array.isArray(tags) || tags.length === 0) return ""; + return tags.join(", "); + }, + + getAreaColor(area) { + if (!area) return '#757575'; // Default color for undefined/null area + + const colors = { + 'main': '#2196F3', + 'fragments': '#4CAF50', + 'solutions': '#FF9800', + 'instruments': '#9C27B0' + }; + return colors[area.toLowerCase()] || '#757575'; + }, + + copyToClipboard(text) { + navigator.clipboard.writeText(text).then(() => { + // Could show a toast notification here + console.log("Copied to clipboard"); + }).catch(err => { + console.error("Failed to copy: ", err); + }); + }, + + async deleteMemory(memory) { + // Confirm deletion + const confirmMessage = `Are you sure you want to delete this memory?\n\nArea: ${memory.area}\nContent: ${memory.content_preview}`; + if (!confirm(confirmMessage)) { + return; + } + + try { + const response = await API.callJsonApi("memory_delete", { + context: getContext(), + memory_id: memory.id + }); + + if (response.success) { + // Close detail modal if the deleted memory is currently shown + if (this.selectedMemory && this.selectedMemory.id === memory.id) { + this.closeDetailModal(); + } + + // Remove memory from local array + this.memories = this.memories.filter(m => m.id !== memory.id); + this.totalCount = this.memories.length; + + // Update statistics + if (memory.knowledge_source) { + this.knowledgeCount = Math.max(0, this.knowledgeCount - 1); + } else { + this.conversationCount = Math.max(0, this.conversationCount - 1); + } + + // Update areas count + if (this.areasCount[memory.area]) { + this.areasCount[memory.area] = Math.max(0, this.areasCount[memory.area] - 1); + if (this.areasCount[memory.area] === 0) { + delete this.areasCount[memory.area]; + } + } + + // Adjust current page if needed + if (this.paginatedMemories.length === 0 && this.currentPage > 1) { + this.currentPage--; + } + + console.log("Memory deleted successfully"); + } else { + alert("Failed to delete memory: " + (response.error || "Unknown error")); + } + } catch (error) { + console.error("Delete memory error:", error); + alert("Failed to delete memory: " + error.message); + } + }, + + // Export functionality (optional) + exportMemories() { + const dataStr = JSON.stringify(this.memories, null, 2); + const dataBlob = new Blob([dataStr], {type: 'application/json'}); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `memory-export-${new Date().toISOString().split('T')[0]}.json`; + link.click(); + URL.revokeObjectURL(url); + } +}; + +const store = createStore("memoryDashboardStore", model); + +export { store }; diff --git a/webui/components/settings/memory/memory-dashboard.html b/webui/components/settings/memory/memory-dashboard.html new file mode 100644 index 000000000..fa8b01392 --- /dev/null +++ b/webui/components/settings/memory/memory-dashboard.html @@ -0,0 +1,1171 @@ + + + + Memory Dashboard + + + + +
+ +
+ + + + + + diff --git a/webui/js/settings.js b/webui/js/settings.js index 06d80c60e..059d64541 100644 --- a/webui/js/settings.js +++ b/webui/js/settings.js @@ -281,6 +281,8 @@ const settingsModalProxy = { openModal("settings/external/a2a-connection.html"); } else if (field.id === "external_api_examples") { openModal("settings/external/api-examples.html"); + } else if (field.id === "memory_dashboard") { + openModal("settings/memory/memory-dashboard.html"); } } }; From a4ef83928f4bdc32f3bde4ccd8aca448b6e66fdc Mon Sep 17 00:00:00 2001 From: Rafael Uzarowski Date: Sun, 24 Aug 2025 19:36:59 +0200 Subject: [PATCH 2/3] fix: review refinements fixed / adjusted: 1) After restart, it says that I need to use the agent first and also the memory subdirectory is selected based on current chat context. Instead there should be a selector of memory subdir before Area filter and the memory should be instantiated with first search. Meaning adding a Search button (just rename apply filters) and showing some loading indicator if memory is being instantiated. 2) Limit set to 500 cannot be changed in UI, it seems like there is exactly 500 memories in the DB. It should be a field in filter. 3) Pagination overflows when there's over 12 pages. The easiest way would be to replace enumerated buttons it with a html Select containing all the pages and leave Previous and Next on the sides for easy clicking. Also pagination should repeat below the table, that should be easy as alpine can bind multiple elements to the same model property. 4) The layout of memory rows is very inefficient, I would recommend merging Area, Timestamp, Source into one column called Metadata. Also the text is barely visible, it should be primary text color. Actions should be limited to minimum required as it is static. 5) There should be a selection column for mass delete, copy and export. You can add property "selected" to each memory item, then a checkbox on the left bound to that property. When at least one memory is selected, we would show a new toolbar at the top below pagination that would show the number of selected memories (this would automatically work across pages thanks to the binding to alpine data), copy contents button, delete button, export to file button. There should also be a check/uncheck all checkbox in the header row so users can quickly select and delete all. 6) The detail modal should be opened by clicking anywhere on the memory row. 7) We should use standard modal functionality, not this custom one, it creates problems, for example clicking outside the detail modal now closes even the dashboard. You can use the same data store, just fill a property like "detailMemory" there and open a modal that uses it. It will also be cleaner to have the modal HTML in another file. 8) The detail modal itself looks good. It should show all metadata in the memory (i don't know if currently there are only selected few or if it shows all, maybe it does). 9) Pressing enter in search field does not trigger the search. 10) Buttons on the right of filter are different height than inputs. --- python/api/memory_dashboard.py | 284 ++++++- .../settings/memory/memory-dashboard-store.js | 630 ++++++++++---- .../settings/memory/memory-dashboard.html | 802 +++++++++++++----- .../settings/memory/memory-detail-modal.html | 137 +++ 4 files changed, 1451 insertions(+), 402 deletions(-) create mode 100644 webui/components/settings/memory/memory-detail-modal.html diff --git a/python/api/memory_dashboard.py b/python/api/memory_dashboard.py index 88579f09e..872bda89a 100644 --- a/python/api/memory_dashboard.py +++ b/python/api/memory_dashboard.py @@ -1,60 +1,269 @@ from python.helpers.api import ApiHandler, Request, Response from python.helpers.memory import Memory +from python.helpers import files +from models import ModelConfig, ModelType class MemoryDashboard(ApiHandler): async def process(self, input: dict, request: Request) -> dict | Response: try: - # Get filter parameters - area_filter = input.get("area", "") # Filter by memory area (MAIN, FRAGMENTS, SOLUTIONS, INSTRUMENTS) + action = input.get("action", "search") + if action == "get_memory_subdirs": + return await self._get_memory_subdirs() + elif action == "get_current_memory_subdir": + return await self._get_current_memory_subdir(request) + elif action == "search": + return await self._search_memories(input) + elif action == "delete": + return await self._delete_memory(input) + elif action == "bulk_delete": + return await self._bulk_delete_memories(input) + else: + return { + "success": False, + "error": f"Unknown action: {action}", + "memories": [], + "total_count": 0 + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "memories": [], + "total_count": 0 + } + + async def _delete_memory(self, input: dict) -> dict: + """Delete a memory by ID from the specified subdirectory.""" + try: + memory_subdir = input.get("memory_subdir", "default") + memory_id = input.get("memory_id") + + if not memory_id: + return { + "success": False, + "error": "Memory ID is required for deletion" + } + + # Check if memory database exists + if Memory.index.get(memory_subdir) is None: + return { + "success": False, + "error": f"Memory database '{memory_subdir}' not initialized" + } + + # Get the MyFaiss database directly + myFaiss_db = Memory.index[memory_subdir] + + # Delete the memory by ID (replicate logic from Memory.delete_documents_by_ids) + rem_docs = await myFaiss_db.aget_by_ids([memory_id]) + if rem_docs: + rem_ids = [doc.metadata["id"] for doc in rem_docs] + await myFaiss_db.adelete(ids=rem_ids) + # Persist changes to disk + Memory._save_db_file(myFaiss_db, memory_subdir) + else: + return { + "success": False, + "error": f"Memory with ID '{memory_id}' not found" + } + + return { + "success": True, + "message": f"Memory {memory_id} deleted successfully" + } + + except Exception as e: + return { + "success": False, + "error": f"Failed to delete memory: {str(e)}" + } + + async def _bulk_delete_memories(self, input: dict) -> dict: + """Delete multiple memories by IDs from the specified subdirectory.""" + try: + memory_subdir = input.get("memory_subdir", "default") + memory_ids = input.get("memory_ids", []) + + if not memory_ids: + return { + "success": False, + "error": "No memory IDs provided for bulk deletion" + } + + if not isinstance(memory_ids, list): + return { + "success": False, + "error": "Memory IDs must be provided as a list" + } + + # Check if memory database exists + if Memory.index.get(memory_subdir) is None: + return { + "success": False, + "error": f"Memory database '{memory_subdir}' not initialized" + } + + # Get the MyFaiss database directly + myFaiss_db = Memory.index[memory_subdir] + + # Delete memories in batch + deleted_count = 0 + failed_ids = [] + + for memory_id in memory_ids: + try: + # Get memory to check if it exists + rem_docs = await myFaiss_db.aget_by_ids([memory_id]) + if rem_docs: + rem_ids = [doc.metadata["id"] for doc in rem_docs] + await myFaiss_db.adelete(ids=rem_ids) + deleted_count += 1 + else: + failed_ids.append(memory_id) + except Exception: + failed_ids.append(memory_id) + + # Persist changes to disk if any deletions were successful + if deleted_count > 0: + Memory._save_db_file(myFaiss_db, memory_subdir) + + if deleted_count == len(memory_ids): + return { + "success": True, + "message": f"Successfully deleted {deleted_count} memories" + } + elif deleted_count > 0: + return { + "success": True, + "message": f"Successfully deleted {deleted_count} memories. {len(failed_ids)} failed: {failed_ids[:5]}" + } + else: + return { + "success": False, + "error": f"Failed to delete any memories. Not found: {failed_ids[:10]}" + } + + except Exception as e: + return { + "success": False, + "error": f"Failed to bulk delete memories: {str(e)}" + } + + async def _get_current_memory_subdir(self, request: Request) -> dict: + """Get the current memory subdirectory from the active context.""" + try: + # Try to get the context from the request + context_id = getattr(request, 'context_id', None) + if not context_id: + # Fallback to default if no context available + return { + "success": True, + "memory_subdir": "default" + } + + # Import AgentContext here to avoid circular imports + from agent import AgentContext + + # Get the context and extract memory subdirectory + context = AgentContext.get(context_id) + if context and hasattr(context, 'config') and hasattr(context.config, 'memory_subdir'): + memory_subdir = context.config.memory_subdir or "default" + return { + "success": True, + "memory_subdir": memory_subdir + } + else: + return { + "success": True, + "memory_subdir": "default" + } + + except Exception: + return { + "success": True, # Still success, just fallback to default + "memory_subdir": "default" + } + + async def _get_memory_subdirs(self) -> dict: + """Get available memory subdirectories.""" + try: + # Get subdirectories from memory folder + subdirs = files.get_subdirectories("memory") + + # Ensure 'default' is always available + if "default" not in subdirs: + subdirs.insert(0, "default") + + return { + "success": True, + "subdirs": subdirs + } + except Exception as e: + return { + "success": False, + "error": f"Failed to get memory subdirectories: {str(e)}", + "subdirs": ["default"] + } + + async def _search_memories(self, input: dict) -> dict: + """Search memories in the specified subdirectory.""" + try: + # Get search parameters + memory_subdir = input.get("memory_subdir", "default") + area_filter = input.get("area", "") # Filter by memory area search_query = input.get("search", "") # Full-text search query limit = input.get("limit", 100) # Number of results to return - # Get context and agent - ctxid = input.get("context", "") - context = self.get_context(ctxid) - - # Check if memory is already initialized to avoid triggering preload - memory_subdir = context.agent0.config.memory_subdir or "default" + # Initialize memory if not already done if Memory.index.get(memory_subdir) is None: - # Memory not initialized yet, return empty results - return { - "success": True, - "memories": [], - "total_count": 0, - "knowledge_count": 0, - "conversation_count": 0, - "areas_count": {}, - "search_query": search_query, - "area_filter": area_filter, - "message": "Memory database not yet initialized. Use the agent first to initialize memory." - } + # Create default embeddings model config + embeddings_config = ModelConfig( + type=ModelType.EMBEDDING, + provider="huggingface", + name="sentence-transformers/all-MiniLM-L6-v2", + ctx_length=512, + limit_requests=0, + limit_input=0, + limit_output=0, + vision=False, + kwargs={} + ) - # Get already initialized memory instance (no initialization triggered) - db = Memory( - agent=context.agent0, - db=Memory.index[memory_subdir], - memory_subdir=memory_subdir, - ) + # Initialize memory database (with log_item=None to prevent status blinking) + db, created = Memory.initialize( + log_item=None, + model_config=embeddings_config, + memory_subdir=memory_subdir, + in_memory=False + ) + + # Store in the Memory index + Memory.index[memory_subdir] = db + + # Get the MyFaiss database directly + myFaiss_db = Memory.index[memory_subdir] memories = [] if search_query: # If search query provided, use similarity search threshold = 0.6 # Lower threshold for broader search in dashboard - filter_expr = f"area == '{area_filter}'" if area_filter else "" + comparator = Memory._get_comparator(f"area == '{area_filter}'") if area_filter else None - docs = await db.search_similarity_threshold( - query=search_query, - limit=limit, - threshold=threshold, - filter=filter_expr + docs = await myFaiss_db.asearch( + search_query, + search_type="similarity_score_threshold", + k=limit, + score_threshold=threshold, + filter=comparator, ) memories = docs else: # If no search query, get all memories from specified area(s) - all_docs = db.db.get_all_docs() + all_docs = myFaiss_db.get_all_docs() for doc_id, doc in all_docs.items(): # Apply area filter if specified @@ -68,7 +277,7 @@ class MemoryDashboard(ApiHandler): break # Format memories for the dashboard - formatted_memories = [] + formatted_memories: list[dict] = [] for memory in memories: metadata = memory.metadata @@ -104,8 +313,8 @@ class MemoryDashboard(ApiHandler): conversation_count = total_memories - knowledge_count areas_count: dict[str, int] = {} - for memory in formatted_memories: - area = memory["area"] + for memory_dict in formatted_memories: + area = memory_dict["area"] areas_count[area] = areas_count.get(area, 0) + 1 return { @@ -116,7 +325,8 @@ class MemoryDashboard(ApiHandler): "conversation_count": conversation_count, "areas_count": areas_count, "search_query": search_query, - "area_filter": area_filter + "area_filter": area_filter, + "memory_subdir": memory_subdir } except Exception as e: diff --git a/webui/components/settings/memory/memory-dashboard-store.js b/webui/components/settings/memory/memory-dashboard-store.js index 1712b3c78..9763c7152 100644 --- a/webui/components/settings/memory/memory-dashboard-store.js +++ b/webui/components/settings/memory/memory-dashboard-store.js @@ -1,77 +1,215 @@ import { createStore } from "/js/AlpineStore.js"; import { getContext } from "/index.js"; import * as API from "/js/api.js"; +import { openModal, closeModal } from "/js/modals.js"; -const model = { +// Memory Dashboard Store +const memoryDashboardStore = { + // Data memories: [], - loading: true, - error: null, - - // Filter states - areaFilter: "", - searchQuery: "", currentPage: 1, itemsPerPage: 10, - // Statistics + // State + loading: false, + loadingSubdirs: false, + initializingMemory: false, + error: null, + message: null, + + // Memory subdirectories + memorySubdirs: [], + selectedMemorySubdir: "default", + memoryInitialized: {}, // Track which subdirs have been initialized + + // Search and filters + searchQuery: "", + areaFilter: "", + limit: parseInt(localStorage.getItem('memoryDashboard_limit') || '100'), + + // Stats totalCount: 0, knowledgeCount: 0, conversationCount: 0, areasCount: {}, - // UI state - selectedMemory: null, - showDetailModal: false, - message: null, // For displaying initialization messages + // Memory detail modal (standard modal approach) + detailMemory: null, + + // Polling + pollingInterval: null, + pollingEnabled: true, async initialize() { - await this.loadMemories(); + // Reset state when opening (but keep directory from context) + this.currentPage = 1; + this.searchQuery = ""; + this.areaFilter = ""; + + // Get current memory subdirectory from application context + await this.getCurrentMemorySubdir(); + + await this.loadMemorySubdirs(); + + // Automatically search with selected subdirectory + if (this.selectedMemorySubdir) { + await this.searchMemories(); + } + + // Start polling for live updates as soon as dashboard is open + this.startPolling(); }, - async loadMemories() { - this.loading = true; + async getCurrentMemorySubdir() { + try { + // Try to get current memory subdirectory from the backend + const response = await API.callJsonApi("memory_dashboard", { + action: "get_current_memory_subdir" + }); + + if (response.success && response.memory_subdir) { + this.selectedMemorySubdir = response.memory_subdir; + } else { + // Fallback to default + this.selectedMemorySubdir = "default"; + } + } catch (error) { + console.error("Failed to get current memory subdirectory:", error); + this.selectedMemorySubdir = "default"; + } + }, + + async loadMemorySubdirs() { + this.loadingSubdirs = true; this.error = null; - this.message = null; try { const response = await API.callJsonApi("memory_dashboard", { - context: getContext(), - area: this.areaFilter, - search: this.searchQuery, - limit: 500 // Get more for client-side pagination + action: "get_memory_subdirs" }); - if (response.success) { - this.memories = response.memories || []; // Already sorted by API + if (response.success) { + let subdirs = response.subdirs || ["default"]; + + // Sort alphabetically but ensure "default" is always first + subdirs = subdirs.filter(dir => dir !== "default").sort(); + if (response.subdirs && response.subdirs.includes("default")) { + subdirs.unshift("default"); + } else { + subdirs.unshift("default"); + } + + this.memorySubdirs = subdirs; + + // Ensure the currently selected subdirectory exists in the list + if (!this.memorySubdirs.includes(this.selectedMemorySubdir)) { + this.selectedMemorySubdir = "default"; + } + } else { + this.error = response.error || "Failed to load memory subdirectories"; + this.memorySubdirs = ["default"]; + this.selectedMemorySubdir = "default"; + } + } catch (error) { + this.error = error.message || "Failed to load memory subdirectories"; + this.memorySubdirs = ["default"]; + // Only fallback to default if current selection is not available + if (!this.memorySubdirs.includes(this.selectedMemorySubdir)) { + this.selectedMemorySubdir = "default"; + } + console.error("Memory subdirectory loading error:", error); + } finally { + this.loadingSubdirs = false; + } + }, + + async searchMemories(silent = false) { + // Save limit to localStorage for persistence + localStorage.setItem('memoryDashboard_limit', this.limit.toString()); + + if (!silent) { + this.loading = true; + this.error = null; + this.message = null; + + // Check if this memory subdirectory needs initialization + if (!this.memoryInitialized[this.selectedMemorySubdir]) { + this.initializingMemory = true; + } + } + + try { + const response = await API.callJsonApi("memory_dashboard", { + action: "search", + memory_subdir: this.selectedMemorySubdir, + area: this.areaFilter, + search: this.searchQuery, + limit: this.limit + }); + + if (response.success) { + // Add selected property to each memory item for mass selection + this.memories = (response.memories || []).map(memory => ({ + ...memory, + selected: memory.selected || false + })); this.totalCount = response.total_count || 0; this.knowledgeCount = response.knowledge_count || 0; this.conversationCount = response.conversation_count || 0; this.areasCount = response.areas_count || {}; - this.message = response.message || null; // Handle initialization messages - this.currentPage = 1; // Reset to first page when loading new data + + if (!silent) { + this.message = response.message || null; + this.currentPage = 1; // Reset to first page when loading new data + } else { + // For silent updates, adjust current page if it exceeds available pages + if (this.currentPage > this.totalPages && this.totalPages > 0) { + this.currentPage = this.totalPages; + } + } + + // Mark this subdirectory as initialized + this.memoryInitialized[this.selectedMemorySubdir] = true; } else { - this.error = response.error || "Failed to load memories"; - this.memories = []; - this.message = null; + if (!silent) { + this.error = response.error || "Failed to search memories"; + this.memories = []; + this.message = null; + } else { + // For silent updates, just log the error but don't break the UI + console.warn("Memory dashboard polling failed:", response.error); + } } } catch (error) { - this.error = error.message || "Failed to load memories"; - this.memories = []; - this.message = null; - console.error("Memory dashboard error:", error); + if (!silent) { + this.error = error.message || "Failed to search memories"; + this.memories = []; + this.message = null; + console.error("Memory search error:", error); + } else { + // For silent updates, just log the error but don't break the UI + console.warn("Memory dashboard polling error:", error); + } } finally { - this.loading = false; + if (!silent) { + this.loading = false; + this.initializingMemory = false; + } } }, - async applyFilters() { - await this.loadMemories(); - }, - - clearFilters() { + async clearSearch() { this.areaFilter = ""; this.searchQuery = ""; - this.applyFilters(); + this.currentPage = 1; + + // Immediately trigger a new search with cleared filters + await this.searchMemories(); + }, + + async onMemorySubdirChange() { + // Clear current results when subdirectory changes + await this.clearSearch(); // Polling continues with new subdirectory }, // Pagination @@ -80,9 +218,9 @@ const model = { }, get paginatedMemories() { - const startIndex = (this.currentPage - 1) * this.itemsPerPage; - const endIndex = startIndex + this.itemsPerPage; - return this.memories.slice(startIndex, endIndex); + const start = (this.currentPage - 1) * this.itemsPerPage; + const end = start + this.itemsPerPage; + return this.memories.slice(start, end); }, goToPage(page) { @@ -103,152 +241,356 @@ const model = { } }, - // Memory details - showMemoryDetails(memory) { - this.selectedMemory = memory; - this.showDetailModal = true; + // Mass selection + get selectedMemories() { + return this.memories.filter(memory => memory.selected); }, - closeDetailModal() { - this.showDetailModal = false; - this.selectedMemory = null; + get selectedCount() { + return this.selectedMemories.length; + }, + + get allSelected() { + return this.memories.length > 0 && this.memories.every(memory => memory.selected); + }, + + get someSelected() { + return this.memories.some(memory => memory.selected); + }, + + toggleSelectAll() { + const shouldSelectAll = !this.allSelected; + this.memories.forEach(memory => { + memory.selected = shouldSelectAll; + }); }, - // Utility methods - formatTimestamp(timestamp, compact = false) { - if (!timestamp || timestamp === "unknown") return "Unknown"; + clearSelection() { + this.memories.forEach(memory => { + memory.selected = false; + }); + }, + + // Bulk operations + async bulkDeleteMemories() { + const selectedMemories = this.selectedMemories; + if (selectedMemories.length === 0) return; + + const confirmMessage = `Are you sure you want to delete ${selectedMemories.length} selected memories? This cannot be undone.`; + if (!confirm(confirmMessage)) return; + try { - // Parse timestamp - assume UTC if no timezone specified - let date; - if (timestamp.includes('T') || timestamp.includes('Z') || timestamp.includes('+')) { - // ISO format with timezone info - date = new Date(timestamp); - } else { - // Assume UTC for plain format "YYYY-MM-DD HH:MM:SS" - date = new Date(timestamp + ' UTC'); - } + this.loading = true; + const response = await API.callJsonApi("memory_dashboard", { + action: "bulk_delete", + memory_subdir: this.selectedMemorySubdir, + memory_ids: selectedMemories.map(memory => memory.id) + }); - // Convert to local time and format - if (compact) { - // Compact format for table view - return date.toLocaleString(undefined, { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }); + if (response.success) { + this.showToast(`Successfully deleted ${selectedMemories.length} memories`, "success"); + + // Let polling refresh the data instead of manual manipulation + // Trigger an immediate refresh to get updated state from backend + await this.searchMemories(true); // silent refresh } else { - // Full format for detail view - return date.toLocaleString(undefined, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false - }); + this.showToast(response.error || "Failed to delete selected memories", "error"); } - } catch { - return timestamp; + } catch (error) { + this.showToast(error.message || "Failed to delete selected memories", "error"); + } finally { + this.loading = false; + } + }, + + // Helper method to format a complete memory with all metadata + formatMemoryForCopy(memory) { + let formatted = `=== Memory ID: ${memory.id} === +Area: ${memory.area} +Timestamp: ${this.formatTimestamp(memory.timestamp)} +Source: ${memory.knowledge_source ? 'Knowledge' : 'Conversation'} +${memory.source_file ? `File: ${memory.source_file}` : ''} +${memory.tags && memory.tags.length > 0 ? `Tags: ${memory.tags.join(', ')}` : ''}`; + + // Add custom metadata if present + if (memory.metadata && typeof memory.metadata === 'object' && Object.keys(memory.metadata).length > 0) { + formatted += '\n\nMetadata:'; + for (const [key, value] of Object.entries(memory.metadata)) { + const displayValue = typeof value === 'object' ? JSON.stringify(value, null, 2) : value; + formatted += `\n${key}: ${displayValue}`; + } + } + + formatted += `\n\nContent: +${memory.content_full} + +`; + return formatted; + }, + + bulkCopyMemories() { + const selectedMemories = this.selectedMemories; + if (selectedMemories.length === 0) return; + + const content = selectedMemories.map(memory => this.formatMemoryForCopy(memory)).join('\n'); + + this.copyToClipboard(content); + this.showToast(`Copied ${selectedMemories.length} memories with metadata to clipboard`, "success"); + }, + + bulkExportMemories() { + const selectedMemories = this.selectedMemories; + if (selectedMemories.length === 0) return; + + const exportData = { + export_timestamp: new Date().toISOString(), + memory_subdir: this.selectedMemorySubdir, + total_memories: selectedMemories.length, + memories: selectedMemories.map(memory => ({ + id: memory.id, + area: memory.area, + timestamp: memory.timestamp, + content: memory.content_full, + tags: memory.tags || [], + knowledge_source: memory.knowledge_source, + source_file: memory.source_file || null, + metadata: memory.metadata || {} + })) + }; + + const jsonString = JSON.stringify(exportData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const timestamp = new Date().toISOString().split('T')[0]; + const filename = `memories_${this.selectedMemorySubdir}_selected_${selectedMemories.length}_${timestamp}.json`; + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + this.showToast(`Exported ${selectedMemories.length} selected memories to ${filename}`, "success"); + }, + + // Memory detail modal (standard approach) + showMemoryDetails(memory) { + this.detailMemory = memory; + // Use global modal system + openModal("settings/memory/memory-detail-modal.html"); + }, + + closeMemoryDetails() { + this.detailMemory = null; + }, + + // Utilities + formatTimestamp(timestamp, compact = false) { + if (!timestamp || timestamp === "unknown") { + return "Unknown"; + } + + const date = new Date(timestamp); + if (isNaN(date.getTime())) { + return "Invalid Date"; + } + + if (compact) { + // For table display: MM/DD HH:mm + return date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + }) + " " + date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit" + }); + } else { + // For details: Full format + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }) + " at " + date.toLocaleTimeString("en-US", { + hour12: true, + hour: "numeric", + minute: "2-digit" + }); } }, formatTags(tags) { - if (!Array.isArray(tags) || tags.length === 0) return ""; + if (!Array.isArray(tags) || tags.length === 0) return "None"; return tags.join(", "); }, - getAreaColor(area) { - if (!area) return '#757575'; // Default color for undefined/null area - + getAreaColor(area) { const colors = { - 'main': '#2196F3', - 'fragments': '#4CAF50', - 'solutions': '#FF9800', - 'instruments': '#9C27B0' + "MAIN": "#3b82f6", // blue + "FRAGMENTS": "#10b981", // emerald + "SOLUTIONS": "#8b5cf6", // violet + "INSTRUMENTS": "#f59e0b" // amber }; - return colors[area.toLowerCase()] || '#757575'; + return colors[area] || "#6b7280"; // gray for unknown }, copyToClipboard(text) { - navigator.clipboard.writeText(text).then(() => { - // Could show a toast notification here - console.log("Copied to clipboard"); - }).catch(err => { - console.error("Failed to copy: ", err); - }); + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => { + this.showToast("Copied to clipboard!", "success"); + }).catch(err => { + console.error("Clipboard copy failed:", err); + this.fallbackCopyToClipboard(text); + }); + } else { + this.fallbackCopyToClipboard(text); + } }, - async deleteMemory(memory) { - // Confirm deletion - const confirmMessage = `Are you sure you want to delete this memory?\n\nArea: ${memory.area}\nContent: ${memory.content_preview}`; - if (!confirm(confirmMessage)) { + fallbackCopyToClipboard(text) { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + this.showToast("Copied to clipboard!", "success"); + } catch (err) { + console.error("Fallback clipboard copy failed:", err); + this.showToast("Failed to copy to clipboard", "error"); + } + document.body.removeChild(textArea); + }, + + async deleteMemory(memory) { + if (!confirm(`Are you sure you want to delete this memory from ${memory.area}?`)) { return; } try { - const response = await API.callJsonApi("memory_delete", { - context: getContext(), + // Check if this is the memory currently being viewed in detail modal + const isViewingThisMemory = this.detailMemory && this.detailMemory.id === memory.id; + + const response = await API.callJsonApi("memory_dashboard", { + action: "delete", + memory_subdir: this.selectedMemorySubdir, memory_id: memory.id }); if (response.success) { - // Close detail modal if the deleted memory is currently shown - if (this.selectedMemory && this.selectedMemory.id === memory.id) { - this.closeDetailModal(); + this.showToast("Memory deleted successfully", "success"); + + // If we were viewing this memory in detail modal, close it + if (isViewingThisMemory) { + this.detailMemory = null; + closeModal(); // Close the detail modal } - // Remove memory from local array - this.memories = this.memories.filter(m => m.id !== memory.id); - this.totalCount = this.memories.length; - - // Update statistics - if (memory.knowledge_source) { - this.knowledgeCount = Math.max(0, this.knowledgeCount - 1); - } else { - this.conversationCount = Math.max(0, this.conversationCount - 1); - } - - // Update areas count - if (this.areasCount[memory.area]) { - this.areasCount[memory.area] = Math.max(0, this.areasCount[memory.area] - 1); - if (this.areasCount[memory.area] === 0) { - delete this.areasCount[memory.area]; - } - } - - // Adjust current page if needed - if (this.paginatedMemories.length === 0 && this.currentPage > 1) { - this.currentPage--; - } - - console.log("Memory deleted successfully"); + // Let polling refresh the data instead of manual manipulation + // Trigger an immediate refresh to get updated state from backend + await this.searchMemories(true); // silent refresh } else { - alert("Failed to delete memory: " + (response.error || "Unknown error")); + this.showToast(`Failed to delete memory: ${response.error}`, "error"); } } catch (error) { - console.error("Delete memory error:", error); - alert("Failed to delete memory: " + error.message); + console.error("Memory deletion error:", error); + this.showToast("Failed to delete memory", "error"); } }, - // Export functionality (optional) exportMemories() { - const dataStr = JSON.stringify(this.memories, null, 2); - const dataBlob = new Blob([dataStr], {type: 'application/json'}); - const url = URL.createObjectURL(dataBlob); - const link = document.createElement('a'); - link.href = url; - link.download = `memory-export-${new Date().toISOString().split('T')[0]}.json`; - link.click(); - URL.revokeObjectURL(url); + if (this.memories.length === 0) { + this.showToast("No memories to export", "warning"); + return; + } + + try { + const exportData = { + memory_subdir: this.selectedMemorySubdir, + export_timestamp: new Date().toISOString(), + total_memories: this.memories.length, + search_query: this.searchQuery, + area_filter: this.areaFilter, + memories: this.memories.map(memory => ({ + id: memory.id, + area: memory.area, + timestamp: memory.timestamp, + content: memory.content_full, + metadata: memory.metadata + })) + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: 'application/json' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `memory-export-${this.selectedMemorySubdir}-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + this.showToast("Memory export completed", "success"); + } catch (error) { + console.error("Memory export error:", error); + this.showToast("Failed to export memories", "error"); + } + }, + + startPolling() { + if (!this.pollingEnabled || this.pollingInterval) { + return; // Already polling or disabled + } + + this.pollingInterval = setInterval(async () => { + // Silently refresh using existing search logic + await this.searchMemories(true); // silent = true + }, 2000); // Poll every 3 seconds - reasonable for active user interactions + }, + + stopPolling() { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + }, + + // Call this when the dialog/component is closed or destroyed + cleanup() { + this.stopPolling(); + // Clear data without triggering a new search (component is being destroyed) + this.areaFilter = ""; + this.searchQuery = ""; + this.memories = []; + this.totalCount = 0; + this.knowledgeCount = 0; + this.conversationCount = 0; + this.areasCount = {}; + this.message = null; + this.currentPage = 1; + }, + + showToast(message, type = "info") { + // Use global toast function if available + if (typeof toast === 'function') { + toast(message, type); + } else { + console.log(`[${type.toUpperCase()}] ${message}`); + } } }; -const store = createStore("memoryDashboardStore", model); +const store = createStore("memoryDashboardStore", memoryDashboardStore); export { store }; diff --git a/webui/components/settings/memory/memory-dashboard.html b/webui/components/settings/memory/memory-dashboard.html index fa8b01392..87761dc5e 100644 --- a/webui/components/settings/memory/memory-dashboard.html +++ b/webui/components/settings/memory/memory-dashboard.html @@ -1,16 +1,10 @@ - + - - Memory Dashboard - - - - -
- +
+ diff --git a/webui/components/settings/memory/memory-detail-modal.html b/webui/components/settings/memory/memory-detail-modal.html new file mode 100644 index 000000000..719c12d3e --- /dev/null +++ b/webui/components/settings/memory/memory-detail-modal.html @@ -0,0 +1,137 @@ +
+ +
+ + From 28900232427383d0c344e78705787f371cba5f6b Mon Sep 17 00:00:00 2001 From: Rafael Uzarowski Date: Tue, 26 Aug 2025 17:44:38 +0200 Subject: [PATCH 3/3] fix: eview refinements for memory dashboard Adressed: 1) When selecting memories using checkbox, it unchecks one second later automatically (Brave+Firefox) 2) Area filter does not work. When All is selecter, it shows me memories from fragments, solutions, etc., but when I select an area, no memories are shown. 3) Modal does not have a title, it shows a path 4) In light mode, buttons and some labels are almost invisible - we should not use custom colors anywhere, we should always use one of the color vars in the screenshot, those are automatically adjusted based on current theme. Also in light mode there's a lot of gray areas that blend, again, we should use our color vars. 5) The loading spinner is stretched into an oval 6) We should merge the first div (title and memory counts) with the third one (pagination) to save space. + we should also show the total number of memories in the DB (unfiltered, just the total count of docs) 7) Action buttons should stack vertically, we can remove the title to make it more narrow 8) We should not use emojis for icons, they break the design, it's used in the source badge (knowledge, conversation...) --- python/api/memory_dashboard.py | 18 +- .../settings/memory/memory-dashboard-store.js | 71 ++- .../settings/memory/memory-dashboard.html | 503 ++++++++++-------- .../settings/memory/memory-detail-modal.html | 264 ++++++++- webui/index.html | 2 +- 5 files changed, 602 insertions(+), 256 deletions(-) diff --git a/python/api/memory_dashboard.py b/python/api/memory_dashboard.py index 872bda89a..d2a3f0d87 100644 --- a/python/api/memory_dashboard.py +++ b/python/api/memory_dashboard.py @@ -253,10 +253,11 @@ class MemoryDashboard(ApiHandler): threshold = 0.6 # Lower threshold for broader search in dashboard comparator = Memory._get_comparator(f"area == '{area_filter}'") if area_filter else None + # Get ALL matching results, don't limit in query docs = await myFaiss_db.asearch( search_query, search_type="similarity_score_threshold", - k=limit, + k=10000, # Get all matches up to reasonable max score_threshold=threshold, filter=comparator, ) @@ -272,10 +273,6 @@ class MemoryDashboard(ApiHandler): memories.append(doc) - # Apply limit - if len(memories) >= limit: - break - # Format memories for the dashboard formatted_memories: list[dict] = [] for memory in memories: @@ -298,7 +295,7 @@ class MemoryDashboard(ApiHandler): formatted_memories.append(memory_data) - # Sort by timestamp (newest first) - handle "unknown" timestamps + # Sort ALL results by timestamp (newest first) - handle "unknown" timestamps def get_sort_key(memory): timestamp = memory["timestamp"] if timestamp == "unknown" or not timestamp: @@ -307,11 +304,19 @@ class MemoryDashboard(ApiHandler): formatted_memories.sort(key=get_sort_key, reverse=True) + # Apply limit AFTER sorting to get the newest entries + if limit and len(formatted_memories) > limit: + formatted_memories = formatted_memories[:limit] + # Get summary statistics total_memories = len(formatted_memories) knowledge_count = sum(1 for m in formatted_memories if m["knowledge_source"]) conversation_count = total_memories - knowledge_count + # Get total count of all memories in database (unfiltered) + all_docs = myFaiss_db.get_all_docs() + total_db_count = len(all_docs) + areas_count: dict[str, int] = {} for memory_dict in formatted_memories: area = memory_dict["area"] @@ -321,6 +326,7 @@ class MemoryDashboard(ApiHandler): "success": True, "memories": formatted_memories, "total_count": total_memories, + "total_db_count": total_db_count, "knowledge_count": knowledge_count, "conversation_count": conversation_count, "areas_count": areas_count, diff --git a/webui/components/settings/memory/memory-dashboard-store.js b/webui/components/settings/memory/memory-dashboard-store.js index 9763c7152..1c33a9239 100644 --- a/webui/components/settings/memory/memory-dashboard-store.js +++ b/webui/components/settings/memory/memory-dashboard-store.js @@ -2,6 +2,12 @@ import { createStore } from "/js/AlpineStore.js"; import { getContext } from "/index.js"; import * as API from "/js/api.js"; import { openModal, closeModal } from "/js/modals.js"; +import { store as notificationStore } from "/components/notifications/notification-store.js"; + +// Helper function for toasts +function justToast(text, type = "info", timeout = 5000) { + notificationStore.addFrontendToastOnly(type, text, "", timeout / 1000); +} // Memory Dashboard Store const memoryDashboardStore = { @@ -29,6 +35,7 @@ const memoryDashboardStore = { // Stats totalCount: 0, + totalDbCount: 0, knowledgeCount: 0, conversationCount: 0, areasCount: {}, @@ -147,13 +154,25 @@ const memoryDashboardStore = { limit: this.limit }); - if (response.success) { + if (response.success) { + // Preserve existing selections when updating memories during polling + const existingSelections = {}; + if (silent && this.memories) { + // Build a map of existing selections by memory ID + this.memories.forEach(memory => { + if (memory.selected) { + existingSelections[memory.id] = true; + } + }); + } + // Add selected property to each memory item for mass selection this.memories = (response.memories || []).map(memory => ({ ...memory, - selected: memory.selected || false + selected: existingSelections[memory.id] || false })); this.totalCount = response.total_count || 0; + this.totalDbCount = response.total_db_count || 0; this.knowledgeCount = response.knowledge_count || 0; this.conversationCount = response.conversation_count || 0; this.areasCount = response.areas_count || {}; @@ -290,16 +309,16 @@ const memoryDashboardStore = { }); if (response.success) { - this.showToast(`Successfully deleted ${selectedMemories.length} memories`, "success"); + justToast(`Successfully deleted ${selectedMemories.length} memories`, "success"); // Let polling refresh the data instead of manual manipulation // Trigger an immediate refresh to get updated state from backend await this.searchMemories(true); // silent refresh } else { - this.showToast(response.error || "Failed to delete selected memories", "error"); + justToast(response.error || "Failed to delete selected memories", "error"); } } catch (error) { - this.showToast(error.message || "Failed to delete selected memories", "error"); + justToast(error.message || "Failed to delete selected memories", "error"); } finally { this.loading = false; } @@ -337,7 +356,7 @@ ${memory.content_full} const content = selectedMemories.map(memory => this.formatMemoryForCopy(memory)).join('\n'); this.copyToClipboard(content); - this.showToast(`Copied ${selectedMemories.length} memories with metadata to clipboard`, "success"); + justToast(`Copied ${selectedMemories.length} memories with metadata to clipboard`, "success"); }, bulkExportMemories() { @@ -375,7 +394,7 @@ ${memory.content_full} document.body.removeChild(a); URL.revokeObjectURL(url); - this.showToast(`Exported ${selectedMemories.length} selected memories to ${filename}`, "success"); + justToast(`Exported ${selectedMemories.length} selected memories to ${filename}`, "success"); }, // Memory detail modal (standard approach) @@ -431,18 +450,18 @@ ${memory.content_full} getAreaColor(area) { const colors = { - "MAIN": "#3b82f6", // blue - "FRAGMENTS": "#10b981", // emerald - "SOLUTIONS": "#8b5cf6", // violet - "INSTRUMENTS": "#f59e0b" // amber + "main": "#3b82f6", + "fragments": "#10b981", + "solutions": "#8b5cf6", + "instruments": "#f59e0b" }; - return colors[area] || "#6b7280"; // gray for unknown + return colors[area] || "#6c757d"; }, copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(() => { - this.showToast("Copied to clipboard!", "success"); + justToast("Copied to clipboard!", "success"); }).catch(err => { console.error("Clipboard copy failed:", err); this.fallbackCopyToClipboard(text); @@ -463,10 +482,10 @@ ${memory.content_full} textArea.select(); try { document.execCommand('copy'); - this.showToast("Copied to clipboard!", "success"); + justToast("Copied to clipboard!", "success"); } catch (err) { console.error("Fallback clipboard copy failed:", err); - this.showToast("Failed to copy to clipboard", "error"); + justToast("Failed to copy to clipboard", "error"); } document.body.removeChild(textArea); }, @@ -487,7 +506,7 @@ ${memory.content_full} }); if (response.success) { - this.showToast("Memory deleted successfully", "success"); + justToast("Memory deleted successfully", "success"); // If we were viewing this memory in detail modal, close it if (isViewingThisMemory) { @@ -499,17 +518,17 @@ ${memory.content_full} // Trigger an immediate refresh to get updated state from backend await this.searchMemories(true); // silent refresh } else { - this.showToast(`Failed to delete memory: ${response.error}`, "error"); + justToast(`Failed to delete memory: ${response.error}`, "error"); } } catch (error) { console.error("Memory deletion error:", error); - this.showToast("Failed to delete memory", "error"); + justToast("Failed to delete memory", "error"); } }, exportMemories() { if (this.memories.length === 0) { - this.showToast("No memories to export", "warning"); + justToast("No memories to export", "warning"); return; } @@ -541,10 +560,10 @@ ${memory.content_full} document.body.removeChild(a); URL.revokeObjectURL(url); - this.showToast("Memory export completed", "success"); + justToast("Memory export completed", "success"); } catch (error) { console.error("Memory export error:", error); - this.showToast("Failed to export memories", "error"); + justToast("Failed to export memories", "error"); } }, @@ -574,6 +593,7 @@ ${memory.content_full} this.searchQuery = ""; this.memories = []; this.totalCount = 0; + this.totalDbCount = 0; this.knowledgeCount = 0; this.conversationCount = 0; this.areasCount = {}; @@ -581,14 +601,7 @@ ${memory.content_full} this.currentPage = 1; }, - showToast(message, type = "info") { - // Use global toast function if available - if (typeof toast === 'function') { - toast(message, type); - } else { - console.log(`[${type.toUpperCase()}] ${message}`); - } - } + }; const store = createStore("memoryDashboardStore", memoryDashboardStore); diff --git a/webui/components/settings/memory/memory-dashboard.html b/webui/components/settings/memory/memory-dashboard.html index 87761dc5e..cad01257d 100644 --- a/webui/components/settings/memory/memory-dashboard.html +++ b/webui/components/settings/memory/memory-dashboard.html @@ -1,29 +1,39 @@ - - + + + Memory Dashboard + + +
- -
-
- Page - of - ( total memories) - -
-
- - - - - -
-
@@ -328,18 +334,38 @@ color: var(--color-text); } - .dashboard-header { + .memory-dashboard h3 { + color: var(--color-text); + margin: 0; + font-size: 1.5rem; + font-weight: 600; + } + + + + .stats-pagination-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--color-border); + padding: 1rem; + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: 8px; + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } - .memory-stats { + .memory-stats-compact { display: flex; gap: 1rem; + flex-wrap: wrap; + } + + .pagination-controls-compact { + display: flex; + align-items: center; + gap: 1rem; } .stat-item { @@ -355,10 +381,15 @@ .stat-label { font-size: 0.8rem; - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.7; margin-bottom: 0.25rem; } + .light-mode .stat-label { + opacity: 0.8; + } + .stat-value { font-size: 1.1rem; font-weight: bold; @@ -407,7 +438,13 @@ .filter-group input:focus, .filter-group select:focus { outline: none; border-color: var(--color-primary); - box-shadow: 0 0 0 2px rgba(115, 122, 129, 0.1); + box-shadow: 0 0 0 2px rgba(115, 122, 129, 0.2); + background: var(--color-input-focus); + } + + .light-mode .filter-group input:focus, + .light-mode .filter-group select:focus { + box-shadow: 0 0 0 2px rgba(56, 70, 83, 0.1); } .filter-actions { @@ -425,14 +462,16 @@ .loading-text { font-size: 0.85rem; - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.7; font-style: italic; } .loading-state, .error-state, .no-memories, .init-message { text-align: center; padding: 2rem; - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.8; background: var(--color-panel); border: 1px solid var(--color-border); border-radius: 8px; @@ -441,24 +480,41 @@ .init-message { color: var(--color-primary); - background: rgba(115, 122, 129, 0.1); + background: rgba(115, 122, 129, 0.2); border-color: var(--color-primary); } + .light-mode .init-message { + background: rgba(56, 70, 83, 0.1); + } + .error-state { color: var(--color-accent); - background: rgba(207, 102, 121, 0.1); + background: rgba(207, 102, 121, 0.2); border-color: var(--color-accent); } + .light-mode .error-state { + background: rgba(176, 0, 32, 0.1); + } + .loading-spinner { - width: 20px; - height: 20px; - border: 2px solid var(--color-border); - border-left-color: var(--color-primary); + width: 24px; + height: 24px; + min-width: 24px; + min-height: 24px; + max-width: 24px; + max-height: 24px; + border: 3px solid var(--color-border); + border-top-color: var(--color-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; + flex-shrink: 0; + flex-grow: 0; + display: inline-block; + position: relative; + box-sizing: border-box; } @keyframes spin { @@ -487,8 +543,8 @@ /* Fixed column widths for proper fit */ .col-select { width: 5%; } .col-metadata { width: 25%; } - .col-preview { width: 55%; } - .col-actions { width: 15%; } + .col-preview { width: 60%; } + .col-actions { width: 10%; } .memory-table th, .memory-table td { @@ -544,12 +600,12 @@ /* Selected row styling */ .memory-row.selected { - background: rgba(115, 122, 129, 0.1); + background: rgba(115, 122, 129, 0.2); border-left: 3px solid var(--color-primary); } .light-mode .memory-row.selected { - background: rgba(115, 122, 129, 0.05); + background: rgba(56, 70, 83, 0.1); } /* Mass action toolbar */ @@ -558,7 +614,7 @@ justify-content: space-between; align-items: center; padding: 1rem; - background: rgba(115, 122, 129, 0.1); + background: rgba(115, 122, 129, 0.2); border: 1px solid var(--color-primary); border-radius: 8px; margin: 1rem 0; @@ -566,7 +622,7 @@ } .light-mode .mass-action-toolbar { - background: rgba(115, 122, 129, 0.05); + background: rgba(56, 70, 83, 0.1); } .selection-info { @@ -596,27 +652,27 @@ .btn-mass:hover { border-color: var(--color-primary); - background: rgba(115, 122, 129, 0.1); + background: var(--color-panel); } .btn-mass.copy:hover { - border-color: #4caf50; - color: #4caf50; + border-color: var(--color-primary); + color: var(--color-primary); } .btn-mass.export:hover { - border-color: #2196f3; - color: #2196f3; + border-color: var(--color-primary); + color: var(--color-primary); } .btn-mass.delete:hover { - border-color: #f44336; - color: #f44336; + border-color: var(--color-accent); + color: var(--color-accent); } .btn-mass.clear:hover { - border-color: #ff9800; - color: #ff9800; + border-color: var(--color-primary); + color: var(--color-primary); } @keyframes slideDown { @@ -650,7 +706,7 @@ } .memory-table tbody tr:hover { - background: rgba(255, 255, 255, 0.1); + background: var(--color-panel); } .memory-table tbody tr:last-child { @@ -663,12 +719,19 @@ color: white; font-size: 0.75rem; font-weight: bold; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.1); + } + + .light-mode .area-badge { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + border: 1px solid rgba(0, 0, 0, 0.1); } .timestamp-cell { font-size: 0.85rem; - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.7; white-space: normal; word-break: break-word; font-family: monospace; @@ -694,20 +757,33 @@ } .source-type.knowledge { - background: rgba(115, 122, 129, 0.1); + background: rgba(115, 122, 129, 0.2); color: var(--color-primary); border-color: var(--color-primary); } + .light-mode .source-type.knowledge { + background: rgba(56, 70, 83, 0.1); + color: var(--color-primary); + } + .source-type.conversation { - background: rgba(101, 101, 101, 0.1); - color: var(--color-secondary); - border-color: var(--color-secondary); + background: rgba(101, 101, 101, 0.2); + color: var(--color-text); + opacity: 0.8; + border-color: var(--color-border); + } + + .light-mode .source-type.conversation { + background: rgba(232, 234, 246, 0.5); + color: var(--color-text); + opacity: 0.9; } .source-file { font-size: 0.7rem; - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.7; font-family: monospace; background: var(--color-panel); padding: 0.2rem 0.4rem; @@ -743,14 +819,35 @@ padding: 0.2rem 0.4rem; border-radius: 6px; font-size: 0.7rem; - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.8; } .actions-cell { - white-space: nowrap; - text-align: center; - min-width: 140px; - padding: 0.5rem 0.25rem !important; + min-width: 60px; + width: 60px; + padding: 0 !important; + height: 60px; + position: relative; + } + + .actions-wrapper { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + .actions-cell .btn-action { + display: block; + margin: 0.1rem 0; + width: 28px; + height: 28px; } .btn-action { @@ -760,7 +857,8 @@ margin: 0 0.1rem; cursor: pointer; border-radius: 6px; - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.7; transition: all 0.2s ease; display: inline-flex; align-items: center; @@ -770,56 +868,43 @@ } .btn-action:hover { - background: rgba(0, 0, 0, 0.05); - border-color: rgba(0, 0, 0, 0.2); - color: var(--color-text); + opacity: 1; + background: var(--color-panel); + border-color: var(--color-primary); + color: var(--color-primary); transform: translateY(-1px); } .btn-action.view:hover { - background: rgba(33, 150, 243, 0.1); - border-color: #2196F3; - color: #2196F3; + background: var(--color-panel); + border-color: var(--color-primary); + color: var(--color-primary); } .btn-action.copy:hover { - background: rgba(76, 175, 80, 0.1); - border-color: #4CAF50; - color: #4CAF50; + background: var(--color-panel); + border-color: var(--color-primary); + color: var(--color-primary); } .btn-action.delete:hover { - background: rgba(244, 67, 54, 0.1); - border-color: #f44336; - color: #f44336; + background: var(--color-panel); + border-color: var(--color-accent); + color: var(--color-accent); } - .pagination-controls-top { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding: 1rem; - background: var(--color-panel); - border: 1px solid var(--color-border); - border-radius: 8px; - } + .pagination-info-inline { - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.7; font-size: 0.9rem; } .pagination-controls { display: flex; - justify-content: center; align-items: center; gap: 0.5rem; - margin: 1rem 0; - padding: 1rem; - background: var(--color-panel); - border: 1px solid var(--color-border); - border-radius: 8px; } .page-select { @@ -836,22 +921,15 @@ .page-select:focus { outline: none; border-color: var(--color-primary); - box-shadow: 0 0 0 2px rgba(115, 122, 129, 0.1); + box-shadow: 0 0 0 2px rgba(115, 122, 129, 0.2); + background: var(--color-input-focus); } - .pagination-controls-top, .pagination-controls-bottom { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem; - background: var(--color-panel); - border-bottom: 1px solid var(--color-border); + .light-mode .page-select:focus { + box-shadow: 0 0 0 2px rgba(56, 70, 83, 0.1); } - .pagination-controls-bottom { - border-top: 1px solid var(--color-border); - border-bottom: none; - } + .export-section { text-align: center; @@ -979,7 +1057,8 @@ .timestamp-badge { background: var(--color-panel); border: 1px solid var(--color-border); - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.8; padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.8rem; @@ -1002,8 +1081,9 @@ .source-badge.conversation { background: rgba(101, 101, 101, 0.1); - color: var(--color-secondary); - border-color: var(--color-secondary); + color: var(--color-text); + opacity: 0.8; + border-color: var(--color-border); } .header-actions { @@ -1189,17 +1269,22 @@ padding: 0.5rem; } - .dashboard-header { + .stats-pagination-header { flex-direction: column; align-items: flex-start; gap: 1rem; } - .memory-stats { + .memory-stats-compact { width: 100%; justify-content: space-between; } + .pagination-controls-compact { + align-self: stretch; + justify-content: center; + } + .filter-row { flex-direction: column; align-items: stretch; @@ -1217,14 +1302,14 @@ } .actions-cell { - min-width: 120px; + min-width: 60px; + width: 60px; } - .col-area { width: 15%; } - .col-timestamp { width: 15%; } - .col-source { width: 25%; } - .col-preview { width: 25%; } - .col-actions { width: 20%; } + .col-select { width: 8%; } + .col-metadata { width: 27%; } + .col-preview { width: 55%; } + .col-actions { width: 10%; } .memory-detail-modal { width: 98%; @@ -1258,20 +1343,10 @@ padding: 1rem; } - .pagination-controls-top { - flex-direction: column; - gap: 1rem; - align-items: stretch; - } + .pagination-controls { flex-wrap: wrap; - justify-content: center; - } - - .page-numbers { - flex-wrap: wrap; - justify-content: center; } } @@ -1324,8 +1399,9 @@ .source-badge.conversation { background: rgba(101, 101, 101, 0.1); - color: var(--color-secondary); - border-color: var(--color-secondary); + color: var(--color-text); + opacity: 0.8; + border-color: var(--color-border); } .header-actions { @@ -1423,7 +1499,8 @@ .metadata-group h5 { margin: 0 0 0.75rem 0; - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.7; font-size: 0.9rem; font-weight: 600; text-transform: uppercase; @@ -1444,7 +1521,8 @@ .metadata-label { font-size: 0.8rem; font-weight: 600; - color: var(--color-secondary); + color: var(--color-text); + opacity: 0.7; text-transform: uppercase; letter-spacing: 0.3px; } @@ -1528,4 +1606,5 @@ - + + diff --git a/webui/components/settings/memory/memory-detail-modal.html b/webui/components/settings/memory/memory-detail-modal.html index 719c12d3e..6857ea2b2 100644 --- a/webui/components/settings/memory/memory-detail-modal.html +++ b/webui/components/settings/memory/memory-detail-modal.html @@ -1,14 +1,261 @@ -
- -
+ + + diff --git a/webui/index.html b/webui/index.html index c36cfaba2..758116612 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1606,7 +1606,7 @@