main
js 703 lines 20 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { getContext } from "/index.js";
3 import * as API from "/js/api.js";
4 import { openModal, closeModal } from "/js/modals.js";
5 import { store as notificationStore } from "/components/notifications/notification-store.js";
6 import {
7 getCurrentUserDateString,
8 getCurrentUserISOString,
9 getUserHour12,
10 getUserTimezone,
11 } from "/js/time-utils.js";
12 const MEMORY_DASHBOARD_API = "/plugins/_memory/memory_dashboard";
13
14 // Helper function for toasts
15 function justToast(text, type = "info", timeout = 5000) {
16 notificationStore.addFrontendToastOnly(type, text, "", timeout / 1000);
17 }
18
19 // Memory Dashboard Store
20 const memoryDashboardStore = {
21 // Data
22 memories: [],
23 currentPage: 1,
24 itemsPerPage: 10,
25
26 // State
27 loading: false,
28 loadingSubdirs: false,
29 initializingMemory: false,
30 error: null,
31 message: null,
32
33 // Memory subdirectories
34 memorySubdirs: [],
35 selectedMemorySubdir: "default",
36 memoryInitialized: {}, // Track which subdirs have been initialized
37
38 // Search and filters
39 searchQuery: "",
40 areaFilter: "",
41 threshold: parseFloat(
42 localStorage.getItem("memoryDashboard_threshold") || "0.6"
43 ),
44 limit: parseInt(localStorage.getItem("memoryDashboard_limit") || "1000"),
45
46 // Stats
47 totalCount: 0,
48 totalDbCount: 0,
49 knowledgeCount: 0,
50 conversationCount: 0,
51 areasCount: {},
52
53 // Memory detail modal (standard modal approach)
54 detailMemory: null,
55 editMode: false,
56 editMemoryBackup: null,
57
58 // Polling
59 pollingInterval: null,
60 pollingEnabled: false,
61
62 async openModal() {
63 await openModal("/plugins/_memory/webui/memory-dashboard.html");
64 },
65
66 init() {
67 this.initialize();
68 },
69
70 async onOpen() {
71 await this.getCurrentMemorySubdir();
72 await this.loadMemorySubdirs();
73 await this.searchMemories();
74 // Start polling for live updates as soon as dashboard is open
75 this.startPolling();
76 },
77
78 async initialize() {
79 // Reset state when opening (but keep directory from context)
80 this.currentPage = 1;
81 this.searchQuery = "";
82 this.areaFilter = "";
83
84 // // Get current memory subdirectory from application context
85 // await this.getCurrentMemorySubdir();
86
87 // await this.loadMemorySubdirs();
88
89 // // Automatically search with selected subdirectory
90 // if (this.selectedMemorySubdir) {
91 // await this.searchMemories();
92 // }
93
94 // // Start polling for live updates as soon as dashboard is open
95 // this.startPolling();
96 },
97
98 async getCurrentMemorySubdir() {
99 try {
100 // Try to get current memory subdirectory from the backend
101 const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
102 action: "get_current_memory_subdir",
103 context_id: getContext(),
104 });
105
106 if (response.success && response.memory_subdir) {
107 this.selectedMemorySubdir = response.memory_subdir;
108 } else {
109 // Fallback to default
110 this.selectedMemorySubdir = "default";
111 }
112 } catch (error) {
113 console.error("Failed to get current memory subdirectory:", error);
114 this.selectedMemorySubdir = "default";
115 }
116 },
117
118 async loadMemorySubdirs() {
119 this.loadingSubdirs = true;
120 this.error = null;
121
122 try {
123 const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
124 action: "get_memory_subdirs",
125 });
126
127 if (response.success) {
128 let subdirs = response.subdirs || ["default"];
129
130 // Sort alphabetically but ensure "default" is always first
131 subdirs = subdirs.filter((dir) => dir !== "default").sort();
132 if (response.subdirs && response.subdirs.includes("default")) {
133 subdirs.unshift("default");
134 } else {
135 subdirs.unshift("default");
136 }
137
138 this.memorySubdirs = subdirs;
139
140 // Ensure the currently selected subdirectory exists in the list
141 if (!this.memorySubdirs.includes(this.selectedMemorySubdir)) {
142 this.selectedMemorySubdir = "default";
143 }
144 } else {
145 this.error = response.error || "Failed to load memory subdirectories";
146 this.memorySubdirs = ["default"];
147 this.selectedMemorySubdir = "default";
148 }
149 } catch (error) {
150 this.error = error.message || "Failed to load memory subdirectories";
151 this.memorySubdirs = ["default"];
152 // Only fallback to default if current selection is not available
153 if (!this.memorySubdirs.includes(this.selectedMemorySubdir)) {
154 this.selectedMemorySubdir = "default";
155 }
156 console.error("Memory subdirectory loading error:", error);
157 } finally {
158 this.loadingSubdirs = false;
159 }
160 },
161
162 async searchMemories(silent = false) {
163 // Save limit to localStorage for persistence
164 localStorage.setItem("memoryDashboard_limit", this.limit.toString());
165 localStorage.setItem(
166 "memoryDashboard_threshold",
167 this.threshold.toString()
168 );
169
170 if (!silent) {
171 this.loading = true;
172 this.error = null;
173 this.message = null;
174
175 // Check if this memory subdirectory needs initialization
176 if (!this.memoryInitialized[this.selectedMemorySubdir]) {
177 this.initializingMemory = true;
178 }
179 }
180
181 try {
182 const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
183 action: "search",
184 memory_subdir: this.selectedMemorySubdir,
185 area: this.areaFilter,
186 search: this.searchQuery,
187 limit: this.limit,
188 threshold: this.threshold,
189 });
190
191 if (response.success) {
192 // Preserve existing selections when updating memories during polling
193 const existingSelections = {};
194 if (silent && this.memories) {
195 // Build a map of existing selections by memory ID
196 this.memories.forEach((memory) => {
197 if (memory.selected) {
198 existingSelections[memory.id] = true;
199 }
200 });
201 }
202
203 // Add selected property to each memory item for mass selection
204 this.memories = (response.memories || []).map((memory) => ({
205 ...memory,
206 selected: existingSelections[memory.id] || false,
207 }));
208 this.totalCount = response.total_count || 0;
209 this.totalDbCount = response.total_db_count || 0;
210 this.knowledgeCount = response.knowledge_count || 0;
211 this.conversationCount = response.conversation_count || 0;
212
213 if (!silent) {
214 this.message = response.message || null;
215 this.currentPage = 1; // Reset to first page when loading new data
216 } else {
217 // For silent updates, adjust current page if it exceeds available pages
218 if (this.currentPage > this.totalPages && this.totalPages > 0) {
219 this.currentPage = this.totalPages;
220 }
221 }
222
223 // Mark this subdirectory as initialized
224 this.memoryInitialized[this.selectedMemorySubdir] = true;
225 } else {
226 if (!silent) {
227 this.error = response.error || "Failed to search memories";
228 this.memories = [];
229 this.message = null;
230 } else {
231 // For silent updates, just log the error but don't break the UI
232 console.warn("Memory dashboard polling failed:", response.error);
233 }
234 }
235 } catch (error) {
236 if (!silent) {
237 this.error = error.message || "Failed to search memories";
238 this.memories = [];
239 this.message = null;
240 console.error("Memory search error:", error);
241 } else {
242 // For silent updates, just log the error but don't break the UI
243 console.warn("Memory dashboard polling error:", error);
244 }
245 } finally {
246 if (!silent) {
247 this.loading = false;
248 this.initializingMemory = false;
249 }
250 }
251 },
252
253 async clearSearch() {
254 this.areaFilter = "";
255 this.searchQuery = "";
256 this.currentPage = 1;
257
258 // Immediately trigger a new search with cleared filters
259 await this.searchMemories();
260 },
261
262 async onMemorySubdirChange() {
263 // Clear current results when subdirectory changes
264 await this.clearSearch(); // Polling continues with new subdirectory
265 },
266
267 // Pagination
268 get totalPages() {
269 return Math.ceil(this.memories.length / this.itemsPerPage);
270 },
271
272 get paginatedMemories() {
273 const start = (this.currentPage - 1) * this.itemsPerPage;
274 const end = start + this.itemsPerPage;
275 return this.memories.slice(start, end);
276 },
277
278 goToPage(page) {
279 if (page >= 1 && page <= this.totalPages) {
280 this.currentPage = page;
281 }
282 },
283
284 nextPage() {
285 if (this.currentPage < this.totalPages) {
286 this.currentPage++;
287 }
288 },
289
290 prevPage() {
291 if (this.currentPage > 1) {
292 this.currentPage--;
293 }
294 },
295
296 // Mass selection
297 get selectedMemories() {
298 return this.memories.filter((memory) => memory.selected);
299 },
300
301 get selectedCount() {
302 return this.selectedMemories.length;
303 },
304
305 get allSelected() {
306 return (
307 this.memories.length > 0 &&
308 this.memories.every((memory) => memory.selected)
309 );
310 },
311
312 get someSelected() {
313 return this.memories.some((memory) => memory.selected);
314 },
315
316 toggleSelectAll() {
317 const shouldSelectAll = !this.allSelected;
318 this.memories.forEach((memory) => {
319 memory.selected = shouldSelectAll;
320 });
321 },
322
323 clearSelection() {
324 this.memories.forEach((memory) => {
325 memory.selected = false;
326 });
327 },
328
329 // Bulk operations
330 async bulkDeleteMemories() {
331 const selectedMemories = this.selectedMemories;
332 if (selectedMemories.length === 0) return;
333
334 try {
335 this.loading = true;
336 const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
337 action: "bulk_delete",
338 memory_subdir: this.selectedMemorySubdir,
339 memory_ids: selectedMemories.map((memory) => memory.id),
340 });
341
342 if (response.success) {
343 justToast(
344 `Successfully deleted ${selectedMemories.length} memories`,
345 "success"
346 );
347
348 // Let polling refresh the data instead of manual manipulation
349 // Trigger an immediate refresh to get updated state from backend
350 await this.searchMemories(true); // silent refresh
351 } else {
352 justToast(
353 response.error || "Failed to delete selected memories",
354 "error"
355 );
356 }
357 } catch (error) {
358 justToast(error.message || "Failed to delete selected memories", "error");
359 } finally {
360 this.loading = false;
361 }
362 },
363
364 // Helper method to format a complete memory with all metadata
365 formatMemoryForCopy(memory) {
366 let formatted = `=== Memory ID: ${memory.id} ===
367 Area: ${memory.area}
368 Timestamp: ${this.formatTimestamp(memory.timestamp)}
369 Source: ${memory.knowledge_source ? "Knowledge" : "Conversation"}
370 ${memory.source_file ? `File: ${memory.source_file}` : ""}
371 ${
372 memory.tags && memory.tags.length > 0 ? `Tags: ${memory.tags.join(", ")}` : ""
373 }`;
374
375 // Add custom metadata if present
376 if (
377 memory.metadata &&
378 typeof memory.metadata === "object" &&
379 Object.keys(memory.metadata).length > 0
380 ) {
381 formatted += "\n\nMetadata:";
382 for (const [key, value] of Object.entries(memory.metadata)) {
383 const displayValue =
384 typeof value === "object" ? JSON.stringify(value, null, 2) : value;
385 formatted += `\n${key}: ${displayValue}`;
386 }
387 }
388
389 formatted += `\n\nContent:
390 ${memory.content_full}
391
392 `;
393 return formatted;
394 },
395
396 bulkCopyMemories() {
397 const selectedMemories = this.selectedMemories;
398 if (selectedMemories.length === 0) return;
399
400 const content = selectedMemories
401 .map((memory) => this.formatMemoryForCopy(memory))
402 .join("\n");
403
404 this.copyToClipboard(content, false);
405 justToast(
406 `Copied ${selectedMemories.length} memories with metadata to clipboard`,
407 "success"
408 );
409 },
410
411 bulkExportMemories() {
412 const selectedMemories = this.selectedMemories;
413 if (selectedMemories.length === 0) return;
414
415 const exportData = {
416 export_timestamp: getCurrentUserISOString(),
417 memory_subdir: this.selectedMemorySubdir,
418 total_memories: selectedMemories.length,
419 memories: selectedMemories.map((memory) => ({
420 id: memory.id,
421 area: memory.area,
422 timestamp: memory.timestamp,
423 content: memory.content_full,
424 tags: memory.tags || [],
425 knowledge_source: memory.knowledge_source,
426 source_file: memory.source_file || null,
427 metadata: memory.metadata || {},
428 })),
429 };
430
431 const jsonString = JSON.stringify(exportData, null, 2);
432 const blob = new Blob([jsonString], { type: "application/json" });
433 const url = URL.createObjectURL(blob);
434
435 const timestamp = getCurrentUserDateString();
436 const filename = `memories_${this.selectedMemorySubdir}_selected_${selectedMemories.length}_${timestamp}.json`;
437
438 const a = document.createElement("a");
439 a.href = url;
440 a.download = filename;
441 document.body.appendChild(a);
442 a.click();
443 document.body.removeChild(a);
444 URL.revokeObjectURL(url);
445
446 justToast(
447 `Exported ${selectedMemories.length} selected memories to ${filename}`,
448 "success"
449 );
450 },
451
452 // Memory detail modal (standard approach)
453 showMemoryDetails(memory) {
454 this.detailMemory = memory;
455 this.editMode = false;
456 this.editMemoryBackup = null;
457 // Use global modal system
458 openModal("/plugins/_memory/webui/memory-detail-modal.html");
459 },
460
461 closeMemoryDetails() {
462 this.detailMemory = null;
463 },
464
465 // Utilities
466 formatTimestamp(timestamp, compact = false) {
467 if (!timestamp || timestamp === "unknown") {
468 return "Unknown";
469 }
470
471 const date = new Date(timestamp);
472 if (isNaN(date.getTime())) {
473 return "Invalid Date";
474 }
475
476 if (compact) {
477 const hour12 = getUserHour12();
478 // For table display: MM/DD HH:mm
479 return new Intl.DateTimeFormat("en-US", {
480 month: "2-digit",
481 day: "2-digit",
482 hour12,
483 hour: hour12 ? "numeric" : "2-digit",
484 minute: "2-digit",
485 timeZone: getUserTimezone(),
486 }).format(date);
487 } else {
488 const hour12 = getUserHour12();
489 // For details: Full format
490 return new Intl.DateTimeFormat("en-US", {
491 year: "numeric",
492 month: "long",
493 day: "numeric",
494 hour12,
495 hour: hour12 ? "numeric" : "2-digit",
496 minute: "2-digit",
497 timeZone: getUserTimezone(),
498 }).format(date);
499 }
500 },
501
502 formatTags(tags) {
503 if (!Array.isArray(tags) || tags.length === 0) return "None";
504 return tags.join(", ");
505 },
506
507 getAreaColor(area) {
508 const colors = {
509 main: "#3b82f6",
510 fragments: "#10b981",
511 solutions: "#8b5cf6",
512 skills: "#f59e0b",
513 };
514 return colors[area] || "#6c757d";
515 },
516
517 copyToClipboard(text, toastSuccess = true) {
518 if (navigator.clipboard && window.isSecureContext) {
519 navigator.clipboard
520 .writeText(text)
521 .then(() => {
522 if(toastSuccess)
523 justToast("Copied to clipboard!", "success");
524 })
525 .catch((err) => {
526 console.error("Clipboard copy failed:", err);
527 this.fallbackCopyToClipboard(text, toastSuccess);
528 });
529 } else {
530 this.fallbackCopyToClipboard(text, toastSuccess);
531 }
532 },
533
534 fallbackCopyToClipboard(text, toastSuccess = true) {
535 const textArea = document.createElement("textarea");
536 textArea.value = text;
537 textArea.style.position = "fixed";
538 textArea.style.left = "-999999px";
539 textArea.style.top = "-999999px";
540 document.body.appendChild(textArea);
541 textArea.focus();
542 textArea.select();
543 try {
544 document.execCommand("copy");
545 if(toastSuccess)
546 justToast("Copied to clipboard!", "success");
547 } catch (err) {
548 console.error("Fallback clipboard copy failed:", err);
549 justToast("Failed to copy to clipboard", "error");
550 }
551 document.body.removeChild(textArea);
552 },
553
554 async deleteMemory(memory) {
555 try {
556 // Check if this is the memory currently being viewed in detail modal
557 const isViewingThisMemory =
558 this.detailMemory && this.detailMemory.id === memory.id;
559
560 const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
561 action: "delete",
562 memory_subdir: this.selectedMemorySubdir,
563 memory_id: memory.id,
564 });
565
566 if (response.success) {
567 justToast("Memory deleted successfully", "success");
568
569 // If we were viewing this memory in detail modal, close it
570 if (isViewingThisMemory) {
571 this.detailMemory = null;
572 closeModal(); // Close the detail modal
573 }
574
575 // Let polling refresh the data instead of manual manipulation
576 // Trigger an immediate refresh to get updated state from backend
577 await this.searchMemories(true); // silent refresh
578 } else {
579 justToast(`Failed to delete memory: ${response.error}`, "error");
580 }
581 } catch (error) {
582 console.error("Memory deletion error:", error);
583 justToast("Failed to delete memory", "error");
584 }
585 },
586
587 exportMemories() {
588 if (this.memories.length === 0) {
589 justToast("No memories to export", "warning");
590 return;
591 }
592
593 try {
594 const exportData = {
595 memory_subdir: this.selectedMemorySubdir,
596 export_timestamp: getCurrentUserISOString(),
597 total_memories: this.memories.length,
598 search_query: this.searchQuery,
599 area_filter: this.areaFilter,
600 memories: this.memories.map((memory) => ({
601 id: memory.id,
602 area: memory.area,
603 timestamp: memory.timestamp,
604 content: memory.content_full,
605 metadata: memory.metadata,
606 })),
607 };
608
609 const blob = new Blob([JSON.stringify(exportData, null, 2)], {
610 type: "application/json",
611 });
612 const url = URL.createObjectURL(blob);
613 const a = document.createElement("a");
614 a.href = url;
615 a.download = `memory-export-${this.selectedMemorySubdir}-${
616 getCurrentUserDateString()
617 }.json`;
618 document.body.appendChild(a);
619 a.click();
620 document.body.removeChild(a);
621 URL.revokeObjectURL(url);
622
623 justToast("Memory export completed", "success");
624 } catch (error) {
625 console.error("Memory export error:", error);
626 justToast("Failed to export memories", "error");
627 }
628 },
629
630 startPolling() {
631 if (!this.pollingEnabled || this.pollingInterval) {
632 return; // Already polling or disabled
633 }
634
635 this.pollingInterval = setInterval(async () => {
636 // Silently refresh using existing search logic
637 await this.searchMemories(true); // silent = true
638 }, 2000); // Poll every 3 seconds - reasonable for active user interactions
639 },
640
641 stopPolling() {
642 if (this.pollingInterval) {
643 clearInterval(this.pollingInterval);
644 this.pollingInterval = null;
645 }
646 },
647
648 // Call this when the dialog/component is closed or destroyed
649 cleanup() {
650 this.stopPolling();
651 // Clear data without triggering a new search (component is being destroyed)
652 this.areaFilter = "";
653 this.searchQuery = "";
654 this.memories = [];
655 this.totalCount = 0;
656 this.totalDbCount = 0;
657 this.knowledgeCount = 0;
658 this.conversationCount = 0;
659 this.areasCount = {};
660 this.message = null;
661 this.currentPage = 1;
662 this.editMemoryBackup;
663 },
664
665 enableEditMode() {
666 this.editMode = true;
667 this.editMemoryBackup = JSON.stringify(this.detailMemory); // store backup
668 },
669
670 cancelEditMode() {
671 this.editMode = false;
672 this.detailMemory = JSON.parse(this.editMemoryBackup); // restore backup
673 },
674
675 async confirmEditMode() {
676 try {
677
678 const response = await API.callJsonApi(MEMORY_DASHBOARD_API, {
679 action: "update",
680 memory_subdir: this.selectedMemorySubdir,
681 original: JSON.parse(this.editMemoryBackup),
682 edited: this.detailMemory,
683 });
684
685 if(response.success){
686 justToast("Memory updated successfully", "success");
687 await this.searchMemories(true); // silent refresh
688 }else{
689 justToast(`Failed to update memory: ${response.error}`, "error");
690 }
691
692 this.editMode = false;
693 this.editMemoryBackup = null; // discard backup
694 } catch (error) {
695 console.error("Error confirming edit mode:", error);
696 justToast("Failed to save memory changes.", "error");
697 }
698 },
699 };
700
701 const store = createStore("memoryDashboardStore", memoryDashboardStore);
702
703 export { store };