agent-zero/plugins/_memory/webui/memory-dashboard-store.js
Alessandro d1827e6c66
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Refactor: use user locale for time displays
Add user-configurable timezone and 12/24-hour preferences, then wire them through settings, runtime snapshots, scheduler payloads, wait handling, notifications, backups, memory, plugin metadata, and frontend formatters.

Keep UTC as the boundary for absolute instants while serializing user-facing dates in the configured or browser-resolved timezone. Preserve scheduler wall-clock inputs in the selected timezone, propagate TZ into desktop/runtime process environments, and restart active desktop sessions when the runtime timezone changes.

Cover the risky paths with timezone regression tests for settings normalization, auto and fixed timezone resolution, scheduler round-trips, memory timestamp conversion, and desktop timezone sync.
2026-05-21 15:26:00 +02:00

703 lines
20 KiB
JavaScript

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";
import {
getCurrentUserDateString,
getCurrentUserISOString,
getUserHour12,
getUserTimezone,
} from "/js/time-utils.js";
const MEMORY_DASHBOARD_API = "/plugins/_memory/memory_dashboard";
// Helper function for toasts
function justToast(text, type = "info", timeout = 5000) {
notificationStore.addFrontendToastOnly(type, text, "", timeout / 1000);
}
// Memory Dashboard Store
const memoryDashboardStore = {
// Data
memories: [],
currentPage: 1,
itemsPerPage: 10,
// 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: "",
threshold: parseFloat(
localStorage.getItem("memoryDashboard_threshold") || "0.6"
),
limit: parseInt(localStorage.getItem("memoryDashboard_limit") || "1000"),
// Stats
totalCount: 0,
totalDbCount: 0,
knowledgeCount: 0,
conversationCount: 0,
areasCount: {},
// Memory detail modal (standard modal approach)
detailMemory: null,
editMode: false,
editMemoryBackup: null,
// Polling
pollingInterval: null,
pollingEnabled: false,
async openModal() {
await openModal("/plugins/_memory/webui/memory-dashboard.html");
},
init() {
this.initialize();
},
async onOpen() {
await this.getCurrentMemorySubdir();
await this.loadMemorySubdirs();
await this.searchMemories();
// Start polling for live updates as soon as dashboard is open
this.startPolling();
},
async initialize() {
// 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 getCurrentMemorySubdir() {
try {
// Try to get current memory subdirectory from the backend
const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
action: "get_current_memory_subdir",
context_id: getContext(),
});
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;
try {
const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
action: "get_memory_subdirs",
});
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());
localStorage.setItem(
"memoryDashboard_threshold",
this.threshold.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_API, {
action: "search",
memory_subdir: this.selectedMemorySubdir,
area: this.areaFilter,
search: this.searchQuery,
limit: this.limit,
threshold: this.threshold,
});
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: 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;
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 {
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) {
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 {
if (!silent) {
this.loading = false;
this.initializingMemory = false;
}
}
},
async clearSearch() {
this.areaFilter = "";
this.searchQuery = "";
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
get totalPages() {
return Math.ceil(this.memories.length / this.itemsPerPage);
},
get paginatedMemories() {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return this.memories.slice(start, end);
},
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--;
}
},
// Mass selection
get selectedMemories() {
return this.memories.filter((memory) => memory.selected);
},
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;
});
},
clearSelection() {
this.memories.forEach((memory) => {
memory.selected = false;
});
},
// Bulk operations
async bulkDeleteMemories() {
const selectedMemories = this.selectedMemories;
if (selectedMemories.length === 0) return;
try {
this.loading = true;
const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
action: "bulk_delete",
memory_subdir: this.selectedMemorySubdir,
memory_ids: selectedMemories.map((memory) => memory.id),
});
if (response.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 {
justToast(
response.error || "Failed to delete selected memories",
"error"
);
}
} catch (error) {
justToast(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, false);
justToast(
`Copied ${selectedMemories.length} memories with metadata to clipboard`,
"success"
);
},
bulkExportMemories() {
const selectedMemories = this.selectedMemories;
if (selectedMemories.length === 0) return;
const exportData = {
export_timestamp: getCurrentUserISOString(),
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 = getCurrentUserDateString();
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);
justToast(
`Exported ${selectedMemories.length} selected memories to ${filename}`,
"success"
);
},
// Memory detail modal (standard approach)
showMemoryDetails(memory) {
this.detailMemory = memory;
this.editMode = false;
this.editMemoryBackup = null;
// Use global modal system
openModal("/plugins/_memory/webui/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) {
const hour12 = getUserHour12();
// For table display: MM/DD HH:mm
return new Intl.DateTimeFormat("en-US", {
month: "2-digit",
day: "2-digit",
hour12,
hour: hour12 ? "numeric" : "2-digit",
minute: "2-digit",
timeZone: getUserTimezone(),
}).format(date);
} else {
const hour12 = getUserHour12();
// For details: Full format
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour12,
hour: hour12 ? "numeric" : "2-digit",
minute: "2-digit",
timeZone: getUserTimezone(),
}).format(date);
}
},
formatTags(tags) {
if (!Array.isArray(tags) || tags.length === 0) return "None";
return tags.join(", ");
},
getAreaColor(area) {
const colors = {
main: "#3b82f6",
fragments: "#10b981",
solutions: "#8b5cf6",
skills: "#f59e0b",
};
return colors[area] || "#6c757d";
},
copyToClipboard(text, toastSuccess = true) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => {
if(toastSuccess)
justToast("Copied to clipboard!", "success");
})
.catch((err) => {
console.error("Clipboard copy failed:", err);
this.fallbackCopyToClipboard(text, toastSuccess);
});
} else {
this.fallbackCopyToClipboard(text, toastSuccess);
}
},
fallbackCopyToClipboard(text, toastSuccess = true) {
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");
if(toastSuccess)
justToast("Copied to clipboard!", "success");
} catch (err) {
console.error("Fallback clipboard copy failed:", err);
justToast("Failed to copy to clipboard", "error");
}
document.body.removeChild(textArea);
},
async deleteMemory(memory) {
try {
// 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_API, {
action: "delete",
memory_subdir: this.selectedMemorySubdir,
memory_id: memory.id,
});
if (response.success) {
justToast("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
}
// 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 {
justToast(`Failed to delete memory: ${response.error}`, "error");
}
} catch (error) {
console.error("Memory deletion error:", error);
justToast("Failed to delete memory", "error");
}
},
exportMemories() {
if (this.memories.length === 0) {
justToast("No memories to export", "warning");
return;
}
try {
const exportData = {
memory_subdir: this.selectedMemorySubdir,
export_timestamp: getCurrentUserISOString(),
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}-${
getCurrentUserDateString()
}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
justToast("Memory export completed", "success");
} catch (error) {
console.error("Memory export error:", error);
justToast("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.totalDbCount = 0;
this.knowledgeCount = 0;
this.conversationCount = 0;
this.areasCount = {};
this.message = null;
this.currentPage = 1;
this.editMemoryBackup;
},
enableEditMode() {
this.editMode = true;
this.editMemoryBackup = JSON.stringify(this.detailMemory); // store backup
},
cancelEditMode() {
this.editMode = false;
this.detailMemory = JSON.parse(this.editMemoryBackup); // restore backup
},
async confirmEditMode() {
try {
const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
action: "update",
memory_subdir: this.selectedMemorySubdir,
original: JSON.parse(this.editMemoryBackup),
edited: this.detailMemory,
});
if(response.success){
justToast("Memory updated successfully", "success");
await this.searchMemories(true); // silent refresh
}else{
justToast(`Failed to update memory: ${response.error}`, "error");
}
this.editMode = false;
this.editMemoryBackup = null; // discard backup
} catch (error) {
console.error("Error confirming edit mode:", error);
justToast("Failed to save memory changes.", "error");
}
},
};
const store = createStore("memoryDashboardStore", memoryDashboardStore);
export { store };