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
This commit is contained in:
kempersc 2025-12-14 19:12:25 +01:00
parent 1d26cade66
commit 22709cc13e
4 changed files with 287 additions and 29 deletions

View file

@ -203,7 +203,7 @@ export interface UseMultiDatabaseRAGReturn {
// Cache management functions
setCacheEnabled: (enabled: boolean) => void;
getCacheStats: () => Promise<CacheStats>;
clearCache: () => Promise<void>;
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<void> => {
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;
}, []);
/**

View file

@ -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<void> {
async clear(options: { localOnly?: boolean } = {}): Promise<{ localCleared: boolean; sharedCleared: boolean }> {
let localCleared = false;
let sharedCleared = false;
// Clear local cache
await new Promise<void>((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<void>((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 };
}
/**

View file

@ -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);

View file

@ -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<CacheStats | null>(null);
const [cacheSimilarityThreshold, setCacheSimilarityThresholdLocal] = useState(0.92);
const [bypassCache, setBypassCache] = useState(false);
const [refreshingMessageId, setRefreshingMessageId] = useState<string | null>(null);
// Refs
const messagesEndRef = useRef<HTMLDivElement>(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 = () => {
<span>{t('clearCache')}</span>
</button>
{/* Bypass Cache Toggle - One-shot fresh query */}
<button
className={`conversation-chat__action-btn ${bypassCache ? 'conversation-chat__action-btn--bypass-active' : ''}`}
onClick={() => {
setBypassCache(!bypassCache);
showNotification(t(bypassCache ? 'bypassCacheDisabled' : 'bypassCacheEnabled'));
}}
title={t('bypassCache')}
type="button"
>
<Zap size={16} />
<span>{t('bypassCache')}</span>
</button>
{/* Cache Status Indicator */}
{lastCacheLookup && (
<div className={`conversation-cache-status ${lastCacheLookup.found ? 'conversation-cache-status--hit' : 'conversation-cache-status--miss'}`}>
@ -1387,6 +1503,18 @@ const ConversationPage: React.FC = () => {
)}
</span>
)}
{/* Refresh button - re-run query bypassing cache */}
<button
className={`conversation-message__refresh-btn ${(message as ConversationMessage & { fromCache?: boolean }).fromCache ? 'conversation-message__refresh-btn--cached' : ''}`}
onClick={() => handleRefreshMessage(message.id)}
disabled={ragLoading || refreshingMessageId === message.id}
title={t('refreshResponse')}
>
<RefreshCw size={12} className={refreshingMessageId === message.id ? 'conversation-message__refresh-icon--spinning' : ''} />
{refreshingMessageId === message.id ? t('refreshingResponse') : t('refreshResponse')}
</button>
<span className="conversation-message__confidence">
{t('confidence')}: {Math.round(message.response.confidence * 100)}%
</span>