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:
parent
1d26cade66
commit
22709cc13e
4 changed files with 287 additions and 29 deletions
|
|
@ -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;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue