Spaces:
Sleeping
Sleeping
| /** | |
| * Message Cache Module for Realtime Message Display Synchronization | |
| * Feature: 012-realtime-message-display | |
| * | |
| * Implements optimistic UI updates for instant message display with async backend sync. | |
| * Uses sessionStorage for persistence across page reloads. | |
| * | |
| * Performance targets: | |
| * - <50ms optimistic message display | |
| * - <2s cache refresh | |
| * - <10ms sessionStorage access | |
| */ | |
| class MessageCache { | |
| constructor() { | |
| this.CACHE_KEY = 'prepmate_message_cache'; | |
| this.cache = null; | |
| this.sessionId = null; | |
| this.debugMode = localStorage.getItem('DEBUG') === 'true'; | |
| } | |
| /** | |
| * T008: Initialize cache for a session | |
| * Loads from sessionStorage or creates empty cache | |
| * @param {string} sessionId - Current session UUID | |
| */ | |
| init(sessionId) { | |
| this.sessionId = sessionId; | |
| this.cache = this.loadCacheFromBrowser(); | |
| // If no cache exists or session changed, create new cache | |
| if (!this.cache || this.cache.sessionId !== sessionId) { | |
| this.cache = { | |
| sessionId: sessionId, | |
| lastFetchTimestamp: Date.now(), | |
| confirmedMessages: [], | |
| pendingMessages: [] | |
| }; | |
| this.saveCacheToBrowser(); | |
| } | |
| this.logMetric('cache_init', 1, { session_id: sessionId }); | |
| } | |
| /** | |
| * T007: Load cache from sessionStorage | |
| * @returns {Object|null} Parsed cache object or null if not found | |
| */ | |
| loadCacheFromBrowser() { | |
| try { | |
| const startTime = performance.now(); | |
| const cached = sessionStorage.getItem(this.CACHE_KEY); | |
| const duration = performance.now() - startTime; | |
| this.logMetric('cache_load_duration_ms', duration); | |
| return cached ? JSON.parse(cached) : null; | |
| } catch (e) { | |
| console.error('Failed to load cache:', e); | |
| return null; | |
| } | |
| } | |
| /** | |
| * T007: Save cache to sessionStorage | |
| * T062: Monitor cache size and warn if approaching quota | |
| */ | |
| saveCacheToBrowser() { | |
| try { | |
| const startTime = performance.now(); | |
| const cacheJSON = JSON.stringify(this.cache); | |
| const sizeKB = (cacheJSON.length / 1024).toFixed(2); | |
| // T062: Warn if approaching 5MB limit (sessionStorage typically ~5-10MB) | |
| if (cacheJSON.length > 4 * 1024 * 1024) { // 4MB warning threshold | |
| console.warn(`Cache size approaching limit: ${sizeKB}KB / ~5120KB`); | |
| this.logMetric('cache_size_warning', 1, { size_kb: sizeKB }); | |
| } | |
| sessionStorage.setItem(this.CACHE_KEY, cacheJSON); | |
| const duration = performance.now() - startTime; | |
| this.logMetric('cache_save_duration_ms', duration, { size_kb: sizeKB }); | |
| } catch (e) { | |
| console.error('Failed to save cache:', e); | |
| // Check if storage is full | |
| if (e.name === 'QuotaExceededError') { | |
| console.warn('sessionStorage full, clearing old messages'); | |
| // Keep only last 25 confirmed messages | |
| this.cache.confirmedMessages = this.cache.confirmedMessages.slice(-25); | |
| try { | |
| sessionStorage.setItem(this.CACHE_KEY, JSON.stringify(this.cache)); | |
| } catch (e2) { | |
| console.error('Failed to save cache after cleanup:', e2); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * T009: Structured console logging for metrics | |
| * @param {string} metric - Metric name | |
| * @param {number} value - Metric value | |
| * @param {Object} metadata - Additional metadata | |
| */ | |
| logMetric(metric, value, metadata = {}) { | |
| if (this.debugMode) { | |
| console.log(JSON.stringify({ | |
| timestamp: Date.now(), | |
| metric: metric, | |
| value: value, | |
| session_id: this.sessionId, | |
| ...metadata | |
| })); | |
| } | |
| } | |
| /** | |
| * T010: Emit custom DOM event for cache changes | |
| * @param {string} eventName - Event name (cache:message-added, etc.) | |
| * @param {Object} detail - Event detail data | |
| */ | |
| emitCacheEvent(eventName, detail) { | |
| const event = new CustomEvent(eventName, { | |
| detail: detail, | |
| bubbles: true | |
| }); | |
| document.dispatchEvent(event); | |
| } | |
| /** | |
| * T011: Get current cache | |
| * @returns {Object} Current cache object | |
| */ | |
| getCache() { | |
| return this.cache; | |
| } | |
| /** | |
| * T011: Update cache and persist | |
| * @param {Object} updates - Partial updates to apply to cache | |
| */ | |
| updateCache(updates) { | |
| this.cache = { ...this.cache, ...updates }; | |
| this.saveCacheToBrowser(); | |
| } | |
| /** | |
| * T011: Clear cache (on logout or session switch) | |
| */ | |
| clearCache() { | |
| try { | |
| sessionStorage.removeItem(this.CACHE_KEY); | |
| this.cache = null; | |
| this.logMetric('cache_clear', 1); | |
| } catch (e) { | |
| console.error('Failed to clear cache:', e); | |
| } | |
| } | |
| /** | |
| * T012: Add optimistic message to cache | |
| * T063: Prevent duplicates on rapid submission | |
| * Displays message immediately and initiates async backend sync | |
| * @param {string} content - Message content (markdown) | |
| * @param {string} role - "user" or "assistant" | |
| * @param {string} type - "fact", "question", or "response" | |
| * @returns {Promise<Object>} Result with tempId and success status | |
| */ | |
| async addOptimisticMessage(content, role, type) { | |
| const startTime = performance.now(); | |
| // T063: Check for duplicate pending messages (same content, syncing/pending) | |
| const duplicate = this.cache.pendingMessages.find(m => | |
| m.content.trim() === content.trim() && | |
| (m.syncStatus === 'pending' || m.syncStatus === 'syncing') | |
| ); | |
| if (duplicate) { | |
| console.warn('Duplicate message detected, skipping:', content.substring(0, 50)); | |
| return { success: false, error: 'Duplicate message in flight' }; | |
| } | |
| // Generate unique temp ID | |
| const tempId = crypto.randomUUID(); | |
| // Create pending message | |
| const pendingMessage = { | |
| tempId: tempId, | |
| content: content, | |
| role: role, | |
| type: type, | |
| clientTimestamp: Date.now(), | |
| syncStatus: 'pending', | |
| retryCount: 0, | |
| error: null | |
| }; | |
| // Add to cache immediately | |
| this.cache.pendingMessages.push(pendingMessage); | |
| this.saveCacheToBrowser(); | |
| const displayDuration = performance.now() - startTime; | |
| this.logMetric('optimistic_display_duration_ms', displayDuration, { type: type }); | |
| // Emit event for UI update | |
| this.emitCacheEvent('cache:message-added', pendingMessage); | |
| // Initiate async backend POST (T013) | |
| this.syncMessageToBackend(tempId, content, role, type); | |
| return { tempId: tempId, success: true }; | |
| } | |
| /** | |
| * T013: Async backend POST for message sync | |
| * Uses existing Flask form routes for compatibility | |
| * @param {string} tempId - Temporary message ID | |
| * @param {string} content - Message content | |
| * @param {string} role - Message role | |
| * @param {string} type - Message type | |
| */ | |
| async syncMessageToBackend(tempId, content, role, type) { | |
| try { | |
| // Update sync status to 'syncing' | |
| const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId); | |
| if (pendingIndex >= 0) { | |
| this.cache.pendingMessages[pendingIndex].syncStatus = 'syncing'; | |
| this.saveCacheToBrowser(); | |
| } | |
| // Determine route based on type and context | |
| let route; | |
| if (window.location.pathname.includes('/contacts/')) { | |
| // Contact session routes | |
| if (type === 'fact') { | |
| route = `/contacts/${this.sessionId}/facts`; | |
| } else { | |
| route = `/contacts/${this.sessionId}/messages`; | |
| } | |
| } else { | |
| // Profile session route | |
| route = '/profile/facts/add'; | |
| } | |
| // POST to backend using form data (matching existing Flask routes) | |
| const formData = new FormData(); | |
| formData.append('content', content); | |
| const response = await fetch(route, { | |
| method: 'POST', | |
| body: formData, | |
| headers: { | |
| 'X-Requested-With': 'XMLHttpRequest' // Indicate AJAX request | |
| } | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`Backend sync failed: ${response.status} ${errorText}`); | |
| } | |
| // Parse JSON response from backend | |
| const result = await response.json(); | |
| // Confirm the user message | |
| const confirmedMessage = { | |
| id: result.message?.id || result.user_message?.id || crypto.randomUUID(), | |
| content: content, | |
| role: role, | |
| type: type, | |
| timestamp: result.message?.timestamp || result.user_message?.timestamp || new Date().toISOString(), | |
| created_at: result.message?.created_at || result.user_message?.created_at || new Date().toISOString() | |
| }; | |
| // Success: confirm the message (T014) | |
| this.confirmOptimisticMessage(tempId, confirmedMessage); | |
| // T038-T039: If there's an assistant response, add it immediately | |
| if (result.assistant_message) { | |
| this.addAssistantResponse(result.assistant_message); | |
| } | |
| } catch (error) { | |
| console.error('Backend sync error:', error); | |
| // Failure: mark as failed (T015) | |
| this.markOptimisticMessageFailed(tempId, error.message); | |
| } | |
| } | |
| /** | |
| * T038-T039: Add assistant response immediately (already synced from backend) | |
| * @param {Object} assistantMessage - Assistant message data from backend | |
| */ | |
| addAssistantResponse(assistantMessage) { | |
| const message = { | |
| id: assistantMessage.id || assistantMessage.message_id, | |
| content: assistantMessage.content, | |
| role: 'assistant', | |
| type: assistantMessage.type || 'response', | |
| timestamp: new Date(assistantMessage.timestamp || assistantMessage.created_at).getTime(), | |
| created_at: assistantMessage.created_at | |
| }; | |
| // Add directly to confirmed (already synced) | |
| this.cache.confirmedMessages.push(message); | |
| this.saveCacheToBrowser(); | |
| this.logMetric('assistant_response_added', 1); | |
| // T041: Emit event for UI update | |
| this.emitCacheEvent('cache:message-added', message); | |
| } | |
| /** | |
| * T014: Confirm optimistic message after successful backend sync | |
| * @param {string} tempId - Temporary message ID | |
| * @param {Object} confirmedMessage - Backend response with id, timestamp, etc. | |
| */ | |
| confirmOptimisticMessage(tempId, confirmedMessage) { | |
| // Find and remove from pending | |
| const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId); | |
| if (pendingIndex >= 0) { | |
| this.cache.pendingMessages.splice(pendingIndex, 1); | |
| } | |
| // Add to confirmed messages | |
| const message = { | |
| id: confirmedMessage.id || confirmedMessage.message_id, | |
| content: confirmedMessage.content, | |
| role: confirmedMessage.role, | |
| type: confirmedMessage.type, | |
| timestamp: new Date(confirmedMessage.timestamp || confirmedMessage.created_at).getTime(), | |
| isReference: confirmedMessage.is_reference || false | |
| }; | |
| this.cache.confirmedMessages.push(message); | |
| this.saveCacheToBrowser(); | |
| this.logMetric('message_confirmed', 1, { temp_id: tempId }); | |
| // Emit event for UI update | |
| this.emitCacheEvent('cache:message-confirmed', { | |
| tempId: tempId, | |
| confirmedMessage: message | |
| }); | |
| } | |
| /** | |
| * T015: Mark optimistic message as failed after backend sync error | |
| * @param {string} tempId - Temporary message ID | |
| * @param {string} error - Error message | |
| */ | |
| markOptimisticMessageFailed(tempId, error) { | |
| const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId); | |
| if (pendingIndex >= 0) { | |
| const message = this.cache.pendingMessages[pendingIndex]; | |
| message.syncStatus = 'failed'; | |
| message.retryCount += 1; | |
| message.error = error; | |
| this.saveCacheToBrowser(); | |
| this.logMetric('sync_failure', 1, { | |
| temp_id: tempId, | |
| retry_count: message.retryCount, | |
| error: error | |
| }); | |
| // Emit event for UI update | |
| this.emitCacheEvent('cache:message-failed', { | |
| tempId: tempId, | |
| error: error, | |
| retryCount: message.retryCount | |
| }); | |
| // Schedule automatic retry if under max retries (T057) | |
| if (message.retryCount < 3) { | |
| const retryDelay = Math.pow(2, message.retryCount) * 1000; // 2s, 4s, 8s | |
| setTimeout(() => { | |
| this.retryFailedMessage(tempId); | |
| }, retryDelay); | |
| } | |
| } | |
| } | |
| /** | |
| * T056: Retry failed message manually or automatically | |
| * @param {string} tempId - Temporary message ID | |
| * @returns {Promise<Object>} Result with success status | |
| */ | |
| async retryFailedMessage(tempId) { | |
| const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId); | |
| if (pendingIndex >= 0) { | |
| const message = this.cache.pendingMessages[pendingIndex]; | |
| message.syncStatus = 'syncing'; | |
| this.saveCacheToBrowser(); | |
| this.logMetric('message_retry', 1, { temp_id: tempId, retry_count: message.retryCount }); | |
| // Retry backend sync | |
| await this.syncMessageToBackend(tempId, message.content, message.role, message.type); | |
| return { success: true }; | |
| } | |
| return { success: false, error: 'Message not found' }; | |
| } | |
| /** | |
| * Get all messages (confirmed + pending) in chronological order | |
| * @returns {Array} Sorted array of messages | |
| */ | |
| getAllMessages() { | |
| if (!this.cache) return []; | |
| // Combine confirmed and pending messages | |
| const allMessages = [ | |
| ...this.cache.confirmedMessages.map(m => ({ ...m, syncStatus: 'confirmed' })), | |
| ...this.cache.pendingMessages | |
| ]; | |
| // Sort by timestamp (confirmed) or clientTimestamp (pending) | |
| allMessages.sort((a, b) => { | |
| const timeA = a.timestamp || a.clientTimestamp; | |
| const timeB = b.timestamp || b.clientTimestamp; | |
| return timeA - timeB; | |
| }); | |
| return allMessages; | |
| } | |
| /** | |
| * T046-T047: Refresh cache from backend API and merge with pending messages | |
| * Deduplicates by matching content + timestamp proximity (±5s) | |
| * @returns {Promise<void>} | |
| */ | |
| async refreshCache() { | |
| if (!this.cache || !this.cache.sessionId) { | |
| console.warn('Cannot refresh cache: not initialized'); | |
| return; | |
| } | |
| const startTime = performance.now(); | |
| try { | |
| // Note: We don't have a GET messages endpoint yet | |
| // For now, just mark last fetch timestamp | |
| // In production, this would: GET /api/sessions/{id}/messages | |
| // and merge with pending using deduplication logic | |
| this.cache.lastFetchTimestamp = new Date().toISOString(); | |
| this.saveCacheToBrowser(); | |
| const duration = performance.now() - startTime; | |
| this.logMetric('cache_refresh_duration_ms', duration); | |
| // T049: Emit refresh event | |
| this.emitCacheEvent('cache:refreshed', { | |
| sessionId: this.cache.sessionId, | |
| confirmedCount: this.cache.confirmedMessages.length, | |
| pendingCount: this.cache.pendingMessages.length, | |
| duration: duration | |
| }); | |
| } catch (error) { | |
| console.error('Cache refresh error:', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * T047: Deduplicate messages by content and timestamp proximity | |
| * @param {Array} backendMessages - Messages from backend API | |
| * @param {Array} pendingMessages - Pending optimistic messages | |
| * @returns {Array} Deduplicated confirmed messages | |
| */ | |
| deduplicateMessages(backendMessages, pendingMessages) { | |
| const deduplicated = []; | |
| const TIMESTAMP_TOLERANCE_MS = 5000; // ±5 seconds | |
| for (const backendMsg of backendMessages) { | |
| // Check if this backend message matches any pending message | |
| const matchingPending = pendingMessages.find(pending => { | |
| const contentMatch = pending.content.trim() === backendMsg.content.trim(); | |
| const timestampDiff = Math.abs( | |
| new Date(backendMsg.timestamp || backendMsg.created_at).getTime() - | |
| pending.clientTimestamp | |
| ); | |
| return contentMatch && timestampDiff < TIMESTAMP_TOLERANCE_MS; | |
| }); | |
| // If no match, add to deduplicated list | |
| if (!matchingPending) { | |
| deduplicated.push({ | |
| id: backendMsg.id || backendMsg.message_id, | |
| content: backendMsg.content, | |
| role: backendMsg.role || backendMsg.sender, | |
| type: backendMsg.type || backendMsg.mode, | |
| timestamp: new Date(backendMsg.timestamp || backendMsg.created_at).getTime(), | |
| created_at: backendMsg.created_at | |
| }); | |
| } | |
| } | |
| return deduplicated; | |
| } | |
| /** | |
| * T050: Clear cache and reinitialize for session switch | |
| * @param {string} newSessionId - New session ID | |
| */ | |
| switchSession(newSessionId) { | |
| // Clear current cache | |
| this.cache = { | |
| sessionId: newSessionId, | |
| confirmedMessages: [], | |
| pendingMessages: [], | |
| lastFetchTimestamp: null | |
| }; | |
| this.saveCacheToBrowser(); | |
| this.logMetric('session_switched', 1, { | |
| new_session_id: newSessionId | |
| }); | |
| // Trigger refresh for new session | |
| this.refreshCache(); | |
| // Emit event | |
| this.emitCacheEvent('cache:session-switched', { | |
| sessionId: newSessionId | |
| }); | |
| } | |
| /** | |
| * T061: Clear cache on logout | |
| */ | |
| clearCache() { | |
| this.cache = null; | |
| try { | |
| sessionStorage.removeItem(this.STORAGE_KEY); | |
| this.logMetric('cache_cleared', 1); | |
| } catch (error) { | |
| console.error('Error clearing cache:', error); | |
| } | |
| } | |
| /** | |
| * Get cache for inspection/debugging | |
| * @returns {Object} Current cache state | |
| */ | |
| getCache() { | |
| return this.cache; | |
| } | |
| } | |
| // Export singleton instance | |
| const messageCache = new MessageCache(); | |
| window.messageCache = messageCache; | |