From 22709cc13e37df216d6bdd08bdc4c01cc3785620 Mon Sep 17 00:00:00 2001 From: kempersc Date: Sun, 14 Dec 2025 19:12:25 +0100 Subject: [PATCH] feat(rag): Add per-message refresh, bypass cache toggle, and cache clear improvements - Add refresh button to assistant messages for re-running queries with fresh results - Highlight refresh button (amber) for cached responses to draw attention - Add spinning icon animation while refreshing - Fix cache clear to return detailed success/failure status for local vs shared cache - Add bypass cache toggle that forces fresh queries (one-shot, resets after query) - Add Dutch/English translations for new UI elements --- frontend/src/hooks/useMultiDatabaseRAG.ts | 10 +- frontend/src/lib/storage/semantic-cache.ts | 64 ++++++---- frontend/src/pages/ConversationPage.css | 110 +++++++++++++++++ frontend/src/pages/ConversationPage.tsx | 132 ++++++++++++++++++++- 4 files changed, 287 insertions(+), 29 deletions(-) diff --git a/frontend/src/hooks/useMultiDatabaseRAG.ts b/frontend/src/hooks/useMultiDatabaseRAG.ts index 25939bf331..286929cd5f 100644 --- a/frontend/src/hooks/useMultiDatabaseRAG.ts +++ b/frontend/src/hooks/useMultiDatabaseRAG.ts @@ -203,7 +203,7 @@ export interface UseMultiDatabaseRAGReturn { // Cache management functions setCacheEnabled: (enabled: boolean) => void; getCacheStats: () => Promise; - clearCache: () => Promise; + clearCache: () => Promise<{ localCleared: boolean; sharedCleared: boolean }>; setCacheSimilarityThreshold: (threshold: number) => void; } @@ -972,10 +972,12 @@ export function useMultiDatabaseRAG(): UseMultiDatabaseRAGReturn { /** * Clear the semantic cache + * @returns Object indicating which caches were cleared */ - const clearCache = useCallback(async (): Promise => { - await semanticCache.clear(); - console.log('[useMultiDatabaseRAG] Semantic cache cleared'); + const clearCache = useCallback(async (): Promise<{ localCleared: boolean; sharedCleared: boolean }> => { + const result = await semanticCache.clear(); + console.log('[useMultiDatabaseRAG] Semantic cache cleared:', result); + return result; }, []); /** diff --git a/frontend/src/lib/storage/semantic-cache.ts b/frontend/src/lib/storage/semantic-cache.ts index 672509d678..f1d1b2fe06 100644 --- a/frontend/src/lib/storage/semantic-cache.ts +++ b/frontend/src/lib/storage/semantic-cache.ts @@ -887,37 +887,55 @@ export class SemanticCache { /** * Clear all cache entries (both tiers) + * @returns Object indicating which caches were cleared */ - async clear(options: { localOnly?: boolean } = {}): Promise { + async clear(options: { localOnly?: boolean } = {}): Promise<{ localCleared: boolean; sharedCleared: boolean }> { + let localCleared = false; + let sharedCleared = false; + // Clear local cache - await new Promise((resolve, reject) => { - if (!this.db) { - resolve(); - return; - } - - const transaction = this.db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.clear(); - - request.onsuccess = () => { - console.log('[SemanticCache] Local cache cleared'); - resolve(); - }; - - request.onerror = () => { - console.error('[SemanticCache] Clear failed:', request.error); - reject(request.error); - }; - }); + try { + await new Promise((resolve, reject) => { + if (!this.db) { + resolve(); + return; + } + + const transaction = this.db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.clear(); + + request.onsuccess = () => { + console.log('[SemanticCache] Local cache cleared'); + resolve(); + }; + + request.onerror = () => { + console.error('[SemanticCache] Clear failed:', request.error); + reject(request.error); + }; + }); + localCleared = true; + } catch (error) { + console.error('[SemanticCache] Failed to clear local cache:', error); + } // Clear shared cache (unless local only) if (!options.localOnly && this.config.enableSharedCache) { - await this.valkeyClient.clear(); - console.log('[SemanticCache] Shared cache cleared'); + sharedCleared = await this.valkeyClient.clear(); + if (sharedCleared) { + console.log('[SemanticCache] Shared cache cleared'); + } else { + console.warn('[SemanticCache] Failed to clear shared cache (Valkey unavailable or error)'); + } + } else if (options.localOnly) { + // If local only was requested, mark shared as "cleared" for reporting purposes + sharedCleared = true; } this.stats = { localHits: 0, sharedHits: 0, misses: 0, totalLookupTimeMs: 0, lookupCount: 0 }; + + return { localCleared, sharedCleared }; } /** diff --git a/frontend/src/pages/ConversationPage.css b/frontend/src/pages/ConversationPage.css index 25142a9aae..36d60f3f99 100644 --- a/frontend/src/pages/ConversationPage.css +++ b/frontend/src/pages/ConversationPage.css @@ -433,6 +433,35 @@ background: #fffbeb; } +/* Bypass Cache Toggle - Active State */ +.conversation-chat__action-btn--bypass-active { + border-color: #8b5cf6; + color: #7c3aed; + background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%); + box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2); +} + +.conversation-chat__action-btn--bypass-active:hover:not(:disabled) { + border-color: #7c3aed; + color: #6d28d9; + background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%); +} + +.conversation-chat__action-btn--bypass-active svg { + animation: pulse-zap 1.5s ease-in-out infinite; +} + +@keyframes pulse-zap { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } +} + /* History dropdown */ .conversation-chat__history-selector { position: relative; @@ -2058,6 +2087,87 @@ height: 12px; } +/* Refresh button on messages */ +.conversation-message__refresh-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: transparent; + color: #6b7280; + border: 1px solid #e5e7eb; + border-radius: 10px; + font-size: 0.6875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + margin-left: 8px; +} + +.conversation-message__refresh-btn:hover:not(:disabled) { + background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%); + border-color: #d1d5db; + color: #374151; +} + +.conversation-message__refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Highlight refresh button for cached responses */ +.conversation-message__refresh-btn--cached { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border-color: #f59e0b; + color: #b45309; +} + +.conversation-message__refresh-btn--cached:hover:not(:disabled) { + background: linear-gradient(135deg, #fde68a 0%, #fcd34d 100%); + border-color: #d97706; + color: #92400e; +} + +/* Spinning animation for refresh icon */ +.conversation-message__refresh-icon--spinning { + animation: refresh-spin 1s linear infinite; +} + +@keyframes refresh-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.conversation-message__refresh-btn svg { + width: 12px; + height: 12px; +} + +/* Dark theme for refresh button */ +[data-theme="dark"] .conversation-message__refresh-btn { + background: transparent; + border-color: #374151; + color: #9ca3af; +} + +[data-theme="dark"] .conversation-message__refresh-btn:hover:not(:disabled) { + background: #374151; + border-color: #4b5563; + color: #e5e7eb; +} + +[data-theme="dark"] .conversation-message__refresh-btn--cached { + background: rgba(245, 158, 11, 0.2); + border-color: #f59e0b; + color: #fbbf24; +} + +[data-theme="dark"] .conversation-message__refresh-btn--cached:hover:not(:disabled) { + background: rgba(245, 158, 11, 0.3); + border-color: #f59e0b; + color: #fcd34d; +} + /* Dark theme for cache components */ [data-theme="dark"] .conversation-cache-status { background: var(--bg-secondary, #1e293b); diff --git a/frontend/src/pages/ConversationPage.tsx b/frontend/src/pages/ConversationPage.tsx index 609dcc2bb0..23a823e921 100644 --- a/frontend/src/pages/ConversationPage.tsx +++ b/frontend/src/pages/ConversationPage.tsx @@ -166,6 +166,14 @@ const TEXT = { cacheStorageUsed: { nl: 'Opslag Gebruikt', en: 'Storage Used' }, clearCache: { nl: 'Cache Wissen', en: 'Clear Cache' }, cacheCleared: { nl: 'Cache gewist', en: 'Cache cleared' }, + cacheClearedLocal: { nl: 'Lokale cache gewist (gedeelde cache niet beschikbaar)', en: 'Local cache cleared (shared cache unavailable)' }, + cacheClearFailed: { nl: 'Cache wissen mislukt', en: 'Failed to clear cache' }, + bypassCache: { nl: 'Cache Overslaan', en: 'Bypass Cache' }, + bypassCacheEnabled: { nl: 'Cache wordt overgeslagen voor volgende vraag', en: 'Cache will be bypassed for next query' }, + bypassCacheDisabled: { nl: 'Cache normaal gebruikt', en: 'Cache used normally' }, + refreshResponse: { nl: 'Vernieuwen', en: 'Refresh' }, + refreshingResponse: { nl: 'Vernieuwen...', en: 'Refreshing...' }, + responseRefreshed: { nl: 'Antwoord vernieuwd', en: 'Response refreshed' }, enableCache: { nl: 'Cache inschakelen', en: 'Enable caching' }, enableCacheDescription: { nl: 'Sla antwoorden op voor vergelijkbare vragen', en: 'Save responses for similar questions' }, fromCache: { nl: 'Uit cache', en: 'From cache' }, @@ -658,6 +666,8 @@ const ConversationPage: React.FC = () => { const [showCachePanel, setShowCachePanel] = useState(false); const [cacheStats, setCacheStats] = useState(null); const [cacheSimilarityThreshold, setCacheSimilarityThresholdLocal] = useState(0.92); + const [bypassCache, setBypassCache] = useState(false); + const [refreshingMessageId, setRefreshingMessageId] = useState(null); // Refs const messagesEndRef = useRef(null); @@ -809,12 +819,21 @@ const ConversationPage: React.FC = () => { const handleClearCache = useCallback(async () => { try { - await clearCache(); + const result = await clearCache(); const stats = await getCacheStats(); setCacheStats(stats); - showNotification(t('cacheCleared')); + + // Show appropriate notification based on result + if (result.localCleared && result.sharedCleared) { + showNotification(t('cacheCleared')); + } else if (result.localCleared && !result.sharedCleared) { + showNotification(t('cacheClearedLocal')); + } else { + showNotification(t('cacheClearFailed')); + } } catch (error) { console.error('Failed to clear cache:', error); + showNotification(t('cacheClearFailed')); } }, [clearCache, getCacheStats, showNotification, t]); @@ -860,8 +879,14 @@ const ConversationPage: React.FC = () => { model: selectedModel, language, conversationHistory: messages, + bypassCache: bypassCache, }); + // Reset bypass cache after query (it's a one-shot toggle) + if (bypassCache) { + setBypassCache(false); + } + // Save to history saveHistory([...history, { question, timestamp: new Date() }]); @@ -919,6 +944,83 @@ const ConversationPage: React.FC = () => { } }; + // Refresh a specific assistant message by re-running the query (bypassing cache) + const handleRefreshMessage = async (assistantMessageId: string) => { + // Find the assistant message and the user message that preceded it + const msgIndex = messages.findIndex(m => m.id === assistantMessageId); + if (msgIndex < 1) return; // Need at least one message before + + // Find the user message before this assistant message + let userMessageIndex = msgIndex - 1; + while (userMessageIndex >= 0 && messages[userMessageIndex].role !== 'user') { + userMessageIndex--; + } + if (userMessageIndex < 0) return; + + const originalQuestion = messages[userMessageIndex].content; + if (!originalQuestion || ragLoading) return; + + // Mark this message as refreshing + setRefreshingMessageId(assistantMessageId); + + // Build conversation history up to (but not including) the current exchange + const historyForContext = messages.slice(0, userMessageIndex); + + try { + const response = await queryRAG(originalQuestion, { + model: selectedModel, + language, + conversationHistory: historyForContext, + bypassCache: true, // Always bypass cache for refresh + }); + + // Update visualization + if (response.visualizationType && response.visualizationType !== 'none') { + setActiveVizType(response.visualizationType); + setActiveVizData(response.visualizationData || null); + } + + // Store retrieved results for social network visualization + if (response.retrievedResults && response.retrievedResults.length > 0) { + setActiveRetrievedResults(response.retrievedResults); + setActiveQueryType(response.queryType || null); + if (response.queryType === 'person') { + setActiveVizType('network'); + } + } + + // Replace the assistant message with fresh response + setMessages(prev => prev.map(msg => + msg.id === assistantMessageId + ? { + ...msg, + content: response.answer, + response, + isLoading: false, + fromCache: false, // Fresh response + cacheSimilarity: undefined, + } + : msg + )); + + showNotification(t('responseRefreshed')); + + } catch (error) { + setMessages(prev => prev.map(msg => + msg.id === assistantMessageId + ? { + ...msg, + content: t('errorConnection'), + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + : msg + )); + } finally { + setRefreshingMessageId(null); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -1248,6 +1350,20 @@ const ConversationPage: React.FC = () => { {t('clearCache')} + {/* Bypass Cache Toggle - One-shot fresh query */} + + {/* Cache Status Indicator */} {lastCacheLookup && (
@@ -1387,6 +1503,18 @@ const ConversationPage: React.FC = () => { )} )} + + {/* Refresh button - re-run query bypassing cache */} + + {t('confidence')}: {Math.round(message.response.confidence * 100)}%