MemPrepMate / src /static /js /message-cache.js
Christian Kniep
add settings and UI/UX improvements
2e18bf2
/**
* 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;