glam/frontend/src/pages/ConversationPage.tsx
kempersc 6c19ef8661 feat(rag): add Rule 46 epistemic provenance tracking
Track full lineage of RAG responses: WHERE data comes from, WHEN it was
retrieved, HOW it was processed (SPARQL/vector/LLM).

Backend changes:
- Add provenance.py with EpistemicProvenance, DataTier, SourceAttribution
- Integrate provenance into MultiSourceRetriever.merge_results()
- Return epistemic_provenance in DSPyQueryResponse

Frontend changes:
- Pass EpistemicProvenance through useMultiDatabaseRAG hook
- Display provenance in ConversationPage (for cache transparency)

Schema fixes:
- Fix truncated example in has_observation.yaml slot definition

References:
- Pavlyshyn's Context Graphs and Data Traces paper
- LinkML ProvenanceBlock schema pattern
2026-01-10 18:42:43 +01:00

2906 lines
120 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ConversationPage.tsx - Conversational Heritage Institution Explorer
*
* A full-featured conversation page that integrates:
* - DSPy RAG pipeline for intelligent responses
* - Multi-database retrieval (Qdrant, Oxigraph, TypeDB)
* - Interactive D3 geo visualizations
* - Dynamic visualization components (maps, timelines, charts)
* - Bilingual support (NL/EN)
*
* Based on the NDEStatsPage and QueryBuilderPage patterns.
*
* © 2025 Netwerk Digitaal Erfgoed & TextPast. All rights reserved.
*/
import React, { useState, useRef, useEffect, useCallback, useMemo, type RefObject } from 'react';
import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
import {
Send,
Loader2,
Sparkles,
AlertCircle,
Copy,
Check,
ChevronDown,
History,
Download,
Upload,
Trash2,
X,
Map,
BarChart3,
Network,
Clock,
Table2,
LayoutGrid,
Maximize2,
Minimize2,
MapPin,
Building2,
Info,
ExternalLink,
Layers,
Database,
Zap,
RefreshCw,
Plus,
Search,
} from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { useUIState } from '../contexts/UIStateContext';
import { useMultiDatabaseRAG, type RAGResponse, type ConversationMessage, type VisualizationType, type InstitutionData, type PaginationState, type RetrievalProgress, type RetrievalStage } from '../hooks/useMultiDatabaseRAG';
import type { CacheStats } from '../lib/storage/semantic-cache';
import { useQdrant } from '../hooks/useQdrant';
import { ConversationMapLibre, ConversationBarChart, ConversationTimeline, ConversationNetworkGraph, ConversationSocialNetworkGraph, ConversationEmbeddingPanel, ConversationKnowledgeGraphPanel, FactualCountDisplay, ReplyTypeIndicator } from '../components/conversation';
import type { RetrievedResult, QueryType } from '../hooks/useMultiDatabaseRAG';
import type { EmbeddingPoint } from '../components/database/EmbeddingProjector';
import type { GraphData, GraphBackend, GraphNode, GraphEdge } from '../components/database/KnowledgeGraphProjector';
import { useNavigate } from 'react-router-dom';
import { ProvenanceTooltip } from '../components/common/ProvenanceTooltip';
import type { EpistemicProvenance } from '../lib/storage/semantic-cache';
import './ConversationPage.css';
// ============================================================================
// Constants
// ============================================================================
// LLM Provider colors and logos
const LLM_PROVIDER_CONFIG = {
zai: { color: '#10b981', logo: '/logos/zai-logo.svg' },
groq: { color: '#f55036', logo: '/logos/groq-logo.svg' },
anthropic: { color: '#7c3aed', logo: '/logos/anthropic-logo.svg' },
openai: { color: '#0ea5e9', logo: '/logos/openai-logo.svg' },
} as const;
// LLM Model configuration - individual models with provider info
const LLM_MODELS = [
// Z.AI Models (Free)
{ id: 'glm-4.7' as const, provider: 'zai' as const, name: 'GLM-4.7', description: 'Meest capabel', tier: 'free' as const },
{ id: 'glm-4.5-flash' as const, provider: 'zai' as const, name: 'GLM-4.5 Flash', description: 'Snel & efficiënt', tier: 'free' as const },
// Groq Models (Free)
{ id: 'llama-3.1-8b-instant' as const, provider: 'groq' as const, name: 'Llama 3.1 (8B)', description: 'Snel & efficiënt', tier: 'free' as const },
{ id: 'llama-3.3-70b-versatile' as const, provider: 'groq' as const, name: 'Llama 3.3 (70B)', description: 'Krachtig & veelzijdig', tier: 'free' as const },
// Anthropic Models (Premium)
{ id: 'claude-sonnet-4-5-20250929' as const, provider: 'anthropic' as const, name: 'Claude Sonnet 4.5', description: 'Beste balans', tier: 'premium' as const },
{ id: 'claude-haiku-4-5-20251001' as const, provider: 'anthropic' as const, name: 'Claude Haiku 4.5', description: 'Snelst', tier: 'premium' as const },
{ id: 'claude-opus-4-5-20251101' as const, provider: 'anthropic' as const, name: 'Claude Opus 4.5', description: 'Meest intelligent', tier: 'premium' as const },
// OpenAI Models (Premium)
{ id: 'gpt-4o' as const, provider: 'openai' as const, name: 'GPT-4o', description: 'Nieuwste flagship', tier: 'premium' as const },
{ id: 'gpt-4o-mini' as const, provider: 'openai' as const, name: 'GPT-4o Mini', description: 'Snel & betaalbaar', tier: 'premium' as const },
];
// LocalStorage keys
const STORAGE_KEYS = {
HISTORY: 'glam-conversation-history',
CONVERSATIONS: 'glam-conversation-saved',
SELECTED_SOURCES: 'glam-selected-sources',
};
// Available data sources - matches backend DataSource enum
type DataSource = 'qdrant' | 'sparql' | 'typedb';
interface DataSourceConfig {
id: DataSource;
name: { nl: string; en: string };
description: { nl: string; en: string };
icon: React.ElementType;
color: string;
}
const DATA_SOURCES: DataSourceConfig[] = [
{
id: 'qdrant',
name: { nl: 'Vector', en: 'Vector' },
description: { nl: 'Semantische zoekopdracht (Qdrant)', en: 'Semantic search (Qdrant)' },
icon: Search,
color: '#6366f1'
},
{
id: 'sparql',
name: { nl: 'Graaf', en: 'Graph' },
description: { nl: 'Kennisgraaf (Oxigraph)', en: 'Knowledge graph (Oxigraph)' },
icon: Network,
color: '#10b981'
},
{
id: 'typedb',
name: { nl: 'TypeDB', en: 'TypeDB' },
description: { nl: 'Relatie-gebaseerd', en: 'Relationship-based' },
icon: Database,
color: '#f59e0b'
},
];
// Bilingual text content
const TEXT = {
pageTitle: { nl: 'Gesprek', en: 'Conversation' },
pageSubtitle: {
nl: 'Verken erfgoedinstellingen in een gesprek',
en: 'Explore heritage institutions through natural conversation'
},
placeholder: {
nl: 'Stel een vraag over erfgoedinstellingen...',
en: 'Ask a question about heritage institutions...'
},
send: { nl: 'Verstuur', en: 'Send' },
thinking: { nl: 'Bezig met nadenken...', en: 'Thinking...' },
searching: { nl: 'Zoeken in databases...', en: 'Searching databases...' },
generatedQuery: { nl: 'Gegenereerde Query', en: 'Generated Query' },
sparqlQuery: { nl: 'SPARQL Query', en: 'SPARQL Query' },
typeqlQuery: { nl: 'TypeQL Query', en: 'TypeQL Query' },
copied: { nl: 'Gekopieerd!', en: 'Copied!' },
copyQuery: { nl: 'Kopieer', en: 'Copy' },
errorTitle: { nl: 'Fout', en: 'Error' },
errorConnection: {
nl: 'Kan geen verbinding maken met de databases. Controleer of de services draaien.',
en: 'Cannot connect to databases. Check if services are running.'
},
welcomeTitle: { nl: 'Erfgoed Gesprek', en: 'Heritage Conversation' },
welcomeDescription: {
nl: 'Stel vragen over erfgoedinstellingen in Nederland en wereldwijd. Ik doorzoek meerdere databases en toon resultaten met interactieve visualisaties.',
en: 'Ask questions about heritage institutions in the Netherlands and worldwide. I search multiple databases and show results with interactive visualizations.'
},
exampleQuestions: { nl: 'Probeer:', en: 'Try:' },
poweredBy: { nl: 'DSPy + Qdrant + Oxigraph + TypeDB', en: 'DSPy + Qdrant + Oxigraph + TypeDB' },
selectModel: { nl: 'Model', en: 'Model' },
history: { nl: 'Geschiedenis', en: 'History' },
clearHistory: { nl: 'Wis', en: 'Clear' },
noHistory: { nl: 'Geen recente vragen', en: 'No recent questions' },
exportConversation: { nl: 'Exporteren', en: 'Export' },
importConversation: { nl: 'Importeren', en: 'Import' },
clearConversation: { nl: 'Wissen', en: 'Clear' },
export: { nl: 'Export', en: 'Export' },
import: { nl: 'Import', en: 'Import' },
clear: { nl: 'Wis', en: 'Clear' },
new: { nl: 'Nieuw', en: 'New' },
newConversation: { nl: 'Nieuw gesprek starten', en: 'Start new conversation' },
embeddings: { nl: 'Embeddings', en: 'Embeddings' },
advanced: { nl: 'Geavanceerd', en: 'Advanced' },
simple: { nl: 'Eenvoudig', en: 'Simple' },
conversationCleared: { nl: 'Conversatie gewist', en: 'Conversation cleared' },
exportSuccess: { nl: 'Conversatie geëxporteerd', en: 'Conversation exported' },
importSuccess: { nl: 'Conversatie geïmporteerd', en: 'Conversation imported' },
importError: { nl: 'Ongeldig bestand', en: 'Invalid file' },
resultsFound: { nl: 'resultaten gevonden', en: 'results found' },
sources: { nl: 'Bronnen', en: 'Sources' },
showMap: { nl: 'Toon op kaart', en: 'Show on map' },
showChart: { nl: 'Toon grafiek', en: 'Show chart' },
showTimeline: { nl: 'Toon tijdlijn', en: 'Show timeline' },
showNetwork: { nl: 'Toon netwerk', en: 'Show network' },
showTable: { nl: 'Toon tabel', en: 'Show table' },
showCard: { nl: 'Toon kaarten', en: 'Show cards' },
showGallery: { nl: 'Toon galerij', en: 'Show gallery' },
expandVisualization: { nl: 'Vergroot', en: 'Expand' },
collapseVisualization: { nl: 'Verklein', en: 'Collapse' },
refreshVisualization: { nl: 'Ververs', en: 'Refresh' },
resizePanel: { nl: 'Sleep om formaat te wijzigen', en: 'Drag to resize' },
noVisualization: { nl: 'Geen visualisatie beschikbaar', en: 'No visualization available' },
institutions: { nl: 'Instellingen', en: 'Institutions' },
viewDetails: { nl: 'Details bekijken', en: 'View details' },
openWebsite: { nl: 'Website openen', en: 'Open website' },
confidence: { nl: 'Betrouwbaarheid', en: 'Confidence' },
showEmbeddings: { nl: 'Embeddings', en: 'Embeddings' },
hideEmbeddings: { nl: 'Verberg', en: 'Hide' },
simpleMode: { nl: 'Eenvoudig', en: 'Simple' },
advancedMode: { nl: 'Geavanceerd', en: 'Advanced' },
embeddingProjector: { nl: 'Embedding Projector', en: 'Embedding Projector' },
loadingEmbeddings: { nl: 'Embeddings laden...', en: 'Loading embeddings...' },
sourcesUsed: { nl: 'Gebruikte bronnen', en: 'Sources used' },
addToContext: { nl: 'Toevoegen aan context', en: 'Add to context' },
pointAddedToContext: { nl: 'Punt toegevoegd aan context', en: 'Point added to context' },
// Cache-related labels
cache: { nl: 'Cache', en: 'Cache' },
cacheSettings: { nl: 'Cache Instellingen', en: 'Cache Settings' },
cacheEnabled: { nl: 'Cache Ingeschakeld', en: 'Cache Enabled' },
cacheDisabled: { nl: 'Cache Uitgeschakeld', en: 'Cache Disabled' },
cacheHit: { nl: 'Cache Hit', en: 'Cache Hit' },
cacheMiss: { nl: 'Cache Miss', en: 'Cache Miss' },
cacheSimilarity: { nl: 'Gelijkenis', en: 'Similarity' },
cacheThreshold: { nl: 'Drempelwaarde', en: 'Threshold' },
cacheThresholdDescription: { nl: 'Hoger = exactere overeenkomst vereist', en: 'Higher = more exact match required' },
cacheHits: { nl: 'Hits', en: 'Hits' },
cacheMisses: { nl: 'Misses', en: 'Misses' },
cacheHitRate: { nl: 'Hitrate', en: 'Hit Rate' },
cacheEntries: { nl: 'Items', en: 'Entries' },
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' },
retryQuery: { nl: 'Opnieuw proberen', en: 'Retry' },
retryingQuery: { nl: 'Opnieuw proberen...', en: 'Retrying...' },
clearCacheAndRetry: { nl: 'Cache wissen en opnieuw', en: 'Clear cache & retry' },
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' },
// Embedding projector mode labels
globalMode: { nl: 'Globaal', en: 'Global' },
contextMode: { nl: 'Context', en: 'Context' },
globalModeDescription: { nl: 'Alle 500 embeddings', en: 'All 500 embeddings' },
contextModeDescription: { nl: 'Alleen RAG resultaten', en: 'RAG results only' },
noContextResults: { nl: 'Geen context resultaten - stel eerst een vraag', en: 'No context results - ask a question first' },
// Pagination labels
loadMore: { nl: 'Meer laden', en: 'Load More' },
loadingMore: { nl: 'Laden...', en: 'Loading...' },
showingResults: { nl: 'Toont', en: 'Showing' },
ofResults: { nl: 'van', en: 'of' },
allResultsLoaded: { nl: 'Alle resultaten geladen', en: 'All results loaded' },
// Infinite scroll
infiniteScroll: { nl: 'Oneindig scrollen', en: 'Infinite scroll' },
infiniteScrollEnabled: { nl: 'Automatisch meer laden bij scrollen', en: 'Auto-load more when scrolling' },
loadingMoreResults: { nl: 'Meer resultaten laden...', en: 'Loading more results...' },
// Data source selection labels
dataSources: { nl: 'Bronnen', en: 'Sources' },
noSourcesSelected: { nl: 'Selecteer minimaal één databron', en: 'Select at least one data source' },
// Knowledge Graph labels
graph: { nl: 'Graaf', en: 'Graph' },
showGraph: { nl: 'Toon Kennisgraaf', en: 'Show Knowledge Graph' },
hideGraph: { nl: 'Verberg Kennisgraaf', en: 'Hide Knowledge Graph' },
};
// Example questions
const EXAMPLE_QUESTIONS = {
nl: [
'Toon alle musea in Amsterdam op de kaart',
'Welke archieven zijn er in Noord-Holland?',
'Hoeveel bibliotheken zijn er per provincie?',
'Wat zijn de best beoordeelde musea?',
'Toon de tijdlijn van opgerichte instellingen',
],
en: [
'Show all museums in Amsterdam on the map',
'What archives are in North Holland?',
'How many libraries are there per province?',
'What are the best rated museums?',
'Show the timeline of founded institutions',
],
};
// Visualization type icons
const VIZ_ICONS: Record<VisualizationType, React.ReactNode> = {
none: null,
map: <Map size={16} />,
chart: <BarChart3 size={16} />,
timeline: <Clock size={16} />,
network: <Network size={16} />,
table: <Table2 size={16} />,
card: <LayoutGrid size={16} />,
gallery: <Building2 size={16} />,
};
// Visualization type short labels for buttons
const VIZ_LABELS: Record<VisualizationType, { nl: string; en: string }> = {
none: { nl: '', en: '' },
map: { nl: 'Kaart', en: 'Map' },
chart: { nl: 'Grafiek', en: 'Chart' },
timeline: { nl: 'Tijdlijn', en: 'Timeline' },
network: { nl: 'Netwerk', en: 'Network' },
table: { nl: 'Tabel', en: 'Table' },
card: { nl: 'Kaarten', en: 'Cards' },
gallery: { nl: 'Galerij', en: 'Gallery' },
};
// Helper function to detect error messages by content (for cached errors that don't have error flag)
const ERROR_MESSAGE_PATTERNS = [
'Er is een fout opgetreden',
'Probeer het later opnieuw',
'Error processing',
'Serverfout',
'verbinding maken',
'Failed to',
'Unable to',
'Something went wrong',
'Kon geen',
'Geen resultaten gevonden',
];
const isErrorMessage = (message: ConversationMessage): boolean => {
// If error flag is set, it's definitely an error
if (message.error) return true;
// Only check assistant messages
if (message.role !== 'assistant') return false;
// Check if content matches known error patterns
if (!message.content) return false;
return ERROR_MESSAGE_PATTERNS.some(pattern =>
message.content.toLowerCase().includes(pattern.toLowerCase())
);
};
// ============================================================================
// Progressive Loading Stages Component
// ============================================================================
// Stage labels for progressive loading indicator
const STAGE_LABELS: Record<RetrievalStage, { nl: string; en: string; icon: string }> = {
idle: { nl: 'Wachten...', en: 'Waiting...', icon: '⏳' },
embedding: { nl: 'Query analyseren...', en: 'Analyzing query...', icon: '🔤' },
cache_check: { nl: 'Cache controleren...', en: 'Checking cache...', icon: '💾' },
geo_detect: { nl: 'Locatie detecteren...', en: 'Detecting location...', icon: '📍' },
qdrant: { nl: 'Vector zoeken...', en: 'Vector search...', icon: '🔍' },
sparql: { nl: 'Kennisgraaf doorzoeken...', en: 'Querying knowledge graph...', icon: '🕸️' },
typedb: { nl: 'TypeDB doorzoeken...', en: 'Querying TypeDB...', icon: '🗃️' },
llm: { nl: 'Antwoord genereren...', en: 'Generating response...', icon: '🤖' },
complete: { nl: 'Voltooid', en: 'Complete', icon: '✅' },
};
interface LoadingStagesProps {
progress: RetrievalProgress;
language: 'nl' | 'en';
}
const LoadingStages: React.FC<LoadingStagesProps> = ({ progress, language }) => {
const { currentStage, stages, detectedPlace, cacheHit, cacheSimilarity, totalElapsed } = progress;
// Filter to show only stages that have started (not pending)
const visibleStages = stages.filter(s => s.status !== 'pending');
// If no stages have started yet, show a simple loading message
if (visibleStages.length === 0 && currentStage === 'idle') {
return (
<div className="loading-stages">
<div className="loading-stage loading-stage--active">
<span className="loading-stage__icon"></span>
<span className="loading-stage__label">
{language === 'nl' ? 'Initialiseren...' : 'Initializing...'}
</span>
<Loader2 className="loading-stage__spinner" size={14} />
</div>
</div>
);
}
return (
<div className="loading-stages">
{visibleStages.map(stageStatus => {
const label = STAGE_LABELS[stageStatus.stage];
const isActive = stageStatus.status === 'active';
const isComplete = stageStatus.status === 'complete';
const isError = stageStatus.status === 'error';
const isSkipped = stageStatus.status === 'skipped';
return (
<div
key={stageStatus.stage}
className={`loading-stage loading-stage--${stageStatus.status}`}
>
<span className="loading-stage__icon">{label.icon}</span>
<span className="loading-stage__label">
{language === 'nl' ? label.nl : label.en}
</span>
{isActive && <Loader2 className="loading-stage__spinner" size={14} />}
{isComplete && stageStatus.count !== undefined && stageStatus.count > 0 && (
<span className="loading-stage__count">{stageStatus.count}</span>
)}
{isComplete && stageStatus.duration !== undefined && (
<span className="loading-stage__duration">{stageStatus.duration}ms</span>
)}
{isSkipped && (
<span className="loading-stage__skipped">
({language === 'nl' ? 'overgeslagen' : 'skipped'})
</span>
)}
{isError && <span className="loading-stage__error"></span>}
</div>
);
})}
{/* Show detected place if available */}
{detectedPlace && (
<div className="loading-stage__place">
📍 {language === 'nl' ? 'Locatie' : 'Location'}: {detectedPlace}
</div>
)}
{/* Show cache hit notification */}
{cacheHit && (
<div className="loading-stage__cache-hit">
<Zap size={14} />
{language === 'nl' ? 'Uit cache geladen!' : 'Loaded from cache!'}
{cacheSimilarity && (
<span className="loading-stage__similarity">
({Math.round(cacheSimilarity * 100)}%)
</span>
)}
</div>
)}
{/* Show elapsed time for complete state */}
{totalElapsed !== undefined && totalElapsed > 0 && currentStage === 'complete' && (
<div className="loading-stage__elapsed">
{language === 'nl' ? 'Totaal' : 'Total'}: {(totalElapsed / 1000).toFixed(1)}s
</div>
)}
</div>
);
};
// ============================================================================
// Sub-Components
// ============================================================================
interface InstitutionCardProps {
institution: InstitutionData;
language: 'nl' | 'en';
}
const InstitutionCard: React.FC<InstitutionCardProps> = ({ institution, language }) => {
const t = (key: keyof typeof TEXT) => TEXT[key][language];
const navigate = useNavigate();
return (
<div className="conversation-institution-card">
<div className="conversation-institution-card__header">
<Building2 size={20} className="conversation-institution-card__icon" />
<h4 className="conversation-institution-card__name">{institution.name}</h4>
{institution.type && (
<span className="conversation-institution-card__type">{institution.type}</span>
)}
</div>
{institution.description && (
<p className="conversation-institution-card__description">
{institution.description.slice(0, 150)}
{institution.description.length > 150 ? '...' : ''}
</p>
)}
<div className="conversation-institution-card__details">
{institution.city && (
<div className="conversation-institution-card__detail">
<MapPin size={14} />
<span>{institution.city}{institution.province ? `, ${institution.province}` : ''}</span>
</div>
)}
{(institution.rating ?? 0) > 0 && (
<div className="conversation-institution-card__detail conversation-institution-card__detail--rating">
<span className="conversation-institution-card__stars">
{'★'.repeat(Math.round(institution.rating ?? 0))}
{'☆'.repeat(5 - Math.round(institution.rating ?? 0))}
</span>
<span>{(institution.rating ?? 0).toFixed(1)}</span>
{(institution.reviews ?? 0) > 0 && (
<span className="conversation-institution-card__reviews">
({institution.reviews} reviews)
</span>
)}
</div>
)}
</div>
<div className="conversation-institution-card__actions">
{institution.website && (
<a
href={institution.website}
target="_blank"
rel="noopener noreferrer"
className="conversation-institution-card__action"
>
<ExternalLink size={14} />
{t('openWebsite')}
</a>
)}
{(institution.latitude && institution.longitude) && (
<button
className="conversation-institution-card__action"
onClick={() => {
const params = new URLSearchParams({
lat: String(institution.latitude),
lon: String(institution.longitude),
zoom: '14',
highlight: institution.name || '',
});
navigate(`/map?${params.toString()}`);
}}
>
<Map size={14} />
{t('showMap')}
</button>
)}
</div>
{(institution.isil || institution.wikidata) && (
<div className="conversation-institution-card__ids">
{institution.isil && (
<span className="conversation-institution-card__id">ISIL: {institution.isil}</span>
)}
{institution.wikidata && (
<a
href={`https://www.wikidata.org/wiki/${institution.wikidata}`}
target="_blank"
rel="noopener noreferrer"
className="conversation-institution-card__id conversation-institution-card__id--link"
>
Wikidata: {institution.wikidata}
</a>
)}
</div>
)}
</div>
);
};
interface VisualizationPanelProps {
type: VisualizationType;
data: RAGResponse['visualizationData'];
language: 'nl' | 'en';
isExpanded: boolean;
onToggleExpand: () => void;
onChangeType: (type: VisualizationType) => void;
/** Retrieved results from RAG for social network visualization */
retrievedResults?: RetrievedResult[] | null;
/** Query type from RAG - 'person' or 'institution' */
queryType?: QueryType | null;
/** Pagination state for loading more results */
paginationState?: PaginationState | null;
/** Whether more results are currently loading */
isLoadingMore?: boolean;
/** Callback to load more results */
onLoadMore?: () => void;
/** Whether infinite scroll is enabled (vs button mode) */
infiniteScrollEnabled?: boolean;
/** Callback to toggle infinite scroll mode */
onToggleInfiniteScroll?: () => void;
/** Reply type classification from backend (e.g., 'factual_count', 'map_points', 'card_list') */
replyType?: RAGResponse['replyType'] | null;
/** Structured content from backend reply classification */
replyContent?: RAGResponse['replyContent'] | null;
/** Secondary reply type for composite visualizations (e.g., map_points when primary is factual_count) */
secondaryReplyType?: RAGResponse['secondaryReplyType'] | null;
/** Structured content for secondary visualization */
secondaryReplyContent?: RAGResponse['secondaryReplyContent'] | null;
}
const VisualizationPanel: React.FC<VisualizationPanelProps> = ({
type,
data,
language,
isExpanded,
onToggleExpand,
onChangeType,
retrievedResults,
queryType,
paginationState,
isLoadingMore,
onLoadMore,
infiniteScrollEnabled = false,
onToggleInfiniteScroll,
replyType,
replyContent,
secondaryReplyType: _secondaryReplyType,
secondaryReplyContent: _secondaryReplyContent,
}) => {
// Alias back for use in JSX (TypeScript doesn't track usage in complex IIFE patterns)
const secondaryReplyType = _secondaryReplyType;
const secondaryReplyContent = _secondaryReplyContent;
const t = (key: keyof typeof TEXT) => TEXT[key][language];
const [selectedInstitutionId, setSelectedInstitutionId] = useState<string | null>(null);
// Infinite scroll for cards and table containers
const handleInfiniteLoad = useCallback(async () => {
if (onLoadMore) {
await onLoadMore();
}
}, [onLoadMore]);
const { scrollContainerRef: cardsScrollRef } = useInfiniteScroll({
onLoadMore: handleInfiniteLoad,
hasMore: paginationState?.hasMore ?? false,
isLoading: isLoadingMore ?? false,
enabled: infiniteScrollEnabled && type === 'card',
threshold: 300,
});
const { scrollContainerRef: tableScrollRef } = useInfiniteScroll({
onLoadMore: handleInfiniteLoad,
hasMore: paginationState?.hasMore ?? false,
isLoading: isLoadingMore ?? false,
enabled: infiniteScrollEnabled && type === 'table',
threshold: 200,
});
// Calculate dimensions based on expanded state
const vizWidth = isExpanded ? Math.min(window.innerWidth - 100, 1200) : 550;
const vizHeight = isExpanded ? 500 : 350;
// Convert institutions to GeoCoordinates for map
const mapCoordinates = useMemo(() => {
if (!data?.coordinates) {
// Fallback: convert institutions with lat/lng to coordinates
return (data?.institutions || [])
.filter(inst => inst.latitude && inst.longitude)
.map(inst => ({
lat: inst.latitude!,
lng: inst.longitude!,
label: inst.name,
type: inst.type,
data: inst,
}));
}
return data.coordinates;
}, [data?.coordinates, data?.institutions]);
// Generate chart data from institutions if not provided
const chartData = useMemo(() => {
if (data?.chartData) return data.chartData;
// Group by type or province
const institutions = data?.institutions || [];
const byType: Record<string, number> = {};
const byProvince: Record<string, number> = {};
institutions.forEach(inst => {
if (inst.type) {
byType[inst.type] = (byType[inst.type] || 0) + 1;
}
if (inst.province) {
byProvince[inst.province] = (byProvince[inst.province] || 0) + 1;
}
});
// Use province if more variety, else type
const dataMap = Object.keys(byProvince).length >= 3 ? byProvince : byType;
const labels = Object.keys(dataMap).slice(0, 10);
const values = labels.map(l => dataMap[l]);
return {
labels,
datasets: [{
label: language === 'nl' ? 'Aantal' : 'Count',
data: values,
}],
};
}, [data?.chartData, data?.institutions, language]);
// Generate timeline events from institutions if not provided
const timelineEvents = useMemo(() => {
if (data?.timeline) return data.timeline;
// Could extract founding dates from institutions if available
return [];
}, [data?.timeline]);
// Handle marker/node clicks
const handleInstitutionClick = useCallback((inst: InstitutionData) => {
setSelectedInstitutionId(inst.id);
console.log('Selected institution:', inst);
}, []);
if (!data || type === 'none') {
return (
<div className="conversation-viz conversation-viz--empty">
<Info size={24} />
<p>{t('noVisualization')}</p>
</div>
);
}
const renderVisualization = () => {
switch (type) {
case 'map':
return (
<div className="conversation-viz__map">
{mapCoordinates.length > 0 ? (
<>
<ConversationMapLibre
coordinates={mapCoordinates}
width={vizWidth}
height={vizHeight}
language={language}
selectedId={selectedInstitutionId}
onMarkerClick={handleInstitutionClick}
showClustering={mapCoordinates.length > 50}
/>
{/* Map pagination status bar */}
{paginationState && (
<div className="conversation-viz__map-pagination">
<span className="conversation-viz__pagination-info">
{mapCoordinates.length} {language === 'nl' ? 'op kaart' : 'on map'} · {paginationState.totalLoaded} {t('ofResults')} {paginationState.estimatedTotal ?? '?'}
</span>
{paginationState.hasMore && (
<button
className="conversation-viz__load-more-btn conversation-viz__load-more-btn--compact"
onClick={onLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<Loader2 size={14} className="conversation-viz__load-more-spinner" />
) : (
<span>+ {t('loadMore')}</span>
)}
</button>
)}
</div>
)}
</>
) : (
<div className="conversation-viz__map-placeholder">
<Map size={48} />
<p>{data.institutions?.length || 0} {t('institutions')}</p>
<p className="conversation-viz__map-hint">
{language === 'nl'
? 'Geen locatiegegevens beschikbaar'
: 'No location data available'}
</p>
</div>
)}
</div>
);
case 'card':
return (
<div className="conversation-viz__cards-container">
<div
className="conversation-viz__cards"
ref={cardsScrollRef as RefObject<HTMLDivElement>}
>
{data.institutions?.map((inst, idx) => (
<InstitutionCard
key={inst.id || idx}
institution={inst}
language={language}
/>
))}
{/* Infinite scroll loading indicator */}
{infiniteScrollEnabled && isLoadingMore && (
<div className="conversation-viz__infinite-loader">
<Loader2 size={20} className="conversation-viz__load-more-spinner" />
<span>{t('loadingMoreResults')}</span>
</div>
)}
</div>
{/* Pagination controls - only show button when infinite scroll is disabled */}
{paginationState && (
<div className="conversation-viz__pagination">
<span className="conversation-viz__pagination-info">
{t('showingResults')} {paginationState.totalLoaded} {t('ofResults')} {paginationState.estimatedTotal ?? '?'}
</span>
{!infiniteScrollEnabled && paginationState.hasMore ? (
<button
className="conversation-viz__load-more-btn"
onClick={onLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 size={16} className="conversation-viz__load-more-spinner" />
{t('loadingMore')}
</>
) : (
t('loadMore')
)}
</button>
) : !paginationState.hasMore ? (
<span className="conversation-viz__all-loaded">
<Check size={14} />
{t('allResultsLoaded')}
</span>
) : null}
</div>
)}
</div>
);
case 'table':
return (
<div className="conversation-viz__table-wrapper">
<div
className="conversation-viz__table-container"
ref={tableScrollRef as RefObject<HTMLDivElement>}
>
<table className="conversation-viz__table">
<thead>
<tr>
<th>{language === 'nl' ? 'Naam' : 'Name'}</th>
<th>{language === 'nl' ? 'Type' : 'Type'}</th>
<th>{language === 'nl' ? 'Plaats' : 'City'}</th>
<th>{language === 'nl' ? 'Provincie' : 'Province'}</th>
</tr>
</thead>
<tbody>
{data.institutions?.map((inst, idx) => (
<tr key={inst.id || idx}>
<td>{inst.name}</td>
<td>{inst.type || '-'}</td>
<td>{inst.city || '-'}</td>
<td>{inst.province || '-'}</td>
</tr>
))}
{/* Infinite scroll loading indicator */}
{infiniteScrollEnabled && isLoadingMore && (
<tr className="conversation-viz__table-loader-row">
<td colSpan={4}>
<div className="conversation-viz__infinite-loader">
<Loader2 size={16} className="conversation-viz__load-more-spinner" />
<span>{t('loadingMoreResults')}</span>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination controls - only show button when infinite scroll is disabled */}
{paginationState && (
<div className="conversation-viz__pagination">
<span className="conversation-viz__pagination-info">
{t('showingResults')} {paginationState.totalLoaded} {t('ofResults')} {paginationState.estimatedTotal ?? '?'}
</span>
{!infiniteScrollEnabled && paginationState.hasMore ? (
<button
className="conversation-viz__load-more-btn"
onClick={onLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 size={16} className="conversation-viz__load-more-spinner" />
{t('loadingMore')}
</>
) : (
t('loadMore')
)}
</button>
) : !paginationState.hasMore ? (
<span className="conversation-viz__all-loaded">
<Check size={14} />
{t('allResultsLoaded')}
</span>
) : null}
</div>
)}
</div>
);
case 'chart':
return (
<div className="conversation-viz__chart">
{chartData.labels.length > 0 ? (
<ConversationBarChart
data={chartData}
width={vizWidth}
height={vizHeight}
language={language}
orientation="vertical"
showValues={true}
animate={true}
title={language === 'nl' ? 'Verdeling' : 'Distribution'}
/>
) : (
<div className="conversation-viz__chart-placeholder">
<BarChart3 size={48} />
<p>{language === 'nl' ? 'Geen grafiekgegevens beschikbaar' : 'No chart data available'}</p>
</div>
)}
</div>
);
case 'timeline':
return (
<div className="conversation-viz__timeline">
{timelineEvents.length > 0 ? (
<ConversationTimeline
events={timelineEvents}
width={vizWidth}
height={Math.min(vizHeight, 250)}
language={language}
showLabels={true}
/>
) : (
<div className="conversation-viz__timeline-placeholder">
<Clock size={48} />
<p>{language === 'nl' ? 'Geen tijdlijngegevens beschikbaar' : 'No timeline data available'}</p>
</div>
)}
</div>
);
case 'network':
// Use social network graph for person queries with retrieved results
if (queryType === 'person' && retrievedResults && retrievedResults.length > 0) {
return (
<div className="conversation-viz__network conversation-viz__social-network">
<ConversationSocialNetworkGraph
retrievedResults={retrievedResults}
width={vizWidth}
height={vizHeight}
language={language}
/>
</div>
);
}
// Fall back to standard network graph for institution relationships
return (
<div className="conversation-viz__network">
{data.graphData && data.graphData.nodes.length > 0 ? (
<ConversationNetworkGraph
data={data.graphData}
width={vizWidth}
height={vizHeight}
language={language}
showLabels={true}
showEdgeLabels={data.graphData.edges.length < 20}
/>
) : (
<div className="conversation-viz__network-placeholder">
<Network size={48} />
<p>{language === 'nl' ? 'Geen netwerkgegevens beschikbaar' : 'No network data available'}</p>
</div>
)}
</div>
);
default:
return null;
}
};
return (
<div className={`conversation-viz ${isExpanded ? 'conversation-viz--expanded' : ''}`}>
<div className="conversation-viz__toolbar">
<div className="conversation-viz__type-selector">
{/* Reply type indicator badge */}
{replyType && (
<ReplyTypeIndicator
replyType={replyType}
visualizationType={type}
language={language}
size="sm"
className="mr-2"
/>
)}
{(['map', 'card', 'table', 'chart', 'timeline', 'network'] as VisualizationType[]).map(vizType => (
<button
key={vizType}
className={`conversation-viz__type-btn ${type === vizType ? 'conversation-viz__type-btn--active' : ''}`}
onClick={() => onChangeType(vizType)}
title={t(`show${vizType.charAt(0).toUpperCase() + vizType.slice(1)}` as keyof typeof TEXT)}
>
{VIZ_ICONS[vizType]}
<span>{VIZ_LABELS[vizType][language]}</span>
</button>
))}
</div>
<div className="conversation-viz__actions">
{/* Infinite scroll toggle - only for card/table views */}
{(type === 'card' || type === 'table') && onToggleInfiniteScroll && (
<button
className={`conversation-viz__action-btn ${infiniteScrollEnabled ? 'conversation-viz__action-btn--active' : ''}`}
onClick={onToggleInfiniteScroll}
title={infiniteScrollEnabled ? t('infiniteScrollEnabled') : t('infiniteScroll')}
>
<span style={{ fontSize: '18px', fontWeight: 'bold' }}></span>
</button>
)}
<button
className="conversation-viz__action-btn"
onClick={onToggleExpand}
title={isExpanded ? t('collapseVisualization') : t('expandVisualization')}
>
{isExpanded ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
<span>{isExpanded ? t('collapseVisualization') : t('expandVisualization')}</span>
</button>
</div>
</div>
<div className="conversation-viz__content">
{/* Special rendering for factual_count reply type */}
{replyType === 'factual_count' && replyContent?.content ? (() => {
// Type guard: when replyType is 'factual_count', content is FactualCountContent
const countContent = replyContent.content as { count?: number; entity_type?: string; region?: string; template_hint?: string };
return (
<>
<FactualCountDisplay
count={countContent.count || 0}
entityType={countContent.entity_type || 'institution'}
region={countContent.region}
templateHint={countContent.template_hint}
language={language}
/>
{/* Secondary visualization: map_points below the count */}
{secondaryReplyType === 'map_points' && secondaryReplyContent?.content && (() => {
const mapContent = secondaryReplyContent.content as {
points?: Array<{ lat: number; lon: number; name: string; ghcid?: string; institution_type?: string; city?: string }>;
bounds?: { north: number; south: number; east: number; west: number };
center?: { lat: number; lon: number };
zoom?: number;
total_count?: number;
};
// Map to GeoCoordinate format expected by ConversationMapLibre
const secondaryMapCoords = (mapContent.points || []).map(p => ({
lat: p.lat,
lng: p.lon,
label: p.name,
type: p.institution_type,
data: {
id: p.ghcid || p.name,
name: p.name,
type: p.institution_type,
city: p.city,
} as InstitutionData,
}));
return secondaryMapCoords.length > 0 ? (
<div className="conversation-viz__secondary-map" style={{ marginTop: '16px' }}>
<ConversationMapLibre
coordinates={secondaryMapCoords}
width={isExpanded ? Math.min(window.innerWidth - 100, 1200) : 550}
height={isExpanded ? 350 : 250}
language={language}
selectedId={selectedInstitutionId}
onMarkerClick={(inst) => setSelectedInstitutionId(inst.id === selectedInstitutionId ? null : inst.id)}
showClustering={secondaryMapCoords.length > 50}
/>
</div>
) : null;
})()}
</>
);
})() : (
renderVisualization()
)}
</div>
{data.institutions && data.institutions.length > 0 && (
<div className="conversation-viz__footer">
<span>{data.institutions.length} {t('resultsFound')}</span>
</div>
)}
</div>
);
};
// ============================================================================
// Main Page Component
// ============================================================================
const ConversationPage: React.FC = () => {
const { language } = useLanguage();
const t = (key: keyof typeof TEXT) => TEXT[key][language];
// RAG hook with cache support
const {
queryRAG,
isLoading: ragLoading,
clearContext,
lastContext,
cacheEnabled,
lastCacheLookup,
setCacheEnabled,
getCacheStats,
clearCache,
setCacheSimilarityThreshold,
// Pagination support
paginationState,
isLoadingMore,
loadMoreResults,
// Progressive loading stages
retrievalProgress,
} = useMultiDatabaseRAG();
// UI State for LLM model selection
const { state: uiState, setLLMModel } = useUIState();
// Qdrant hook for embedding projector
const { scrollPoints, status: qdrantStatus } = useQdrant();
// State
const [messages, setMessages] = useState<ConversationMessage[]>([]);
const [input, setInput] = useState('');
const [showModelDropdown, setShowModelDropdown] = useState(false);
const [showHistoryDropdown, setShowHistoryDropdown] = useState(false);
const [history, setHistory] = useState<Array<{ question: string; timestamp: Date }>>([]);
const [notification, setNotification] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [vizExpanded, setVizExpanded] = useState(false);
const [activeVizType, setActiveVizType] = useState<VisualizationType>('card');
const [activeVizData, setActiveVizData] = useState<RAGResponse['visualizationData'] | null>(null);
const [activeRetrievedResults, setActiveRetrievedResults] = useState<RetrievedResult[] | null>(null);
const [activeQueryType, setActiveQueryType] = useState<QueryType | null>(null);
// Reply type classification from backend classify_and_format()
const [activeReplyType, setActiveReplyType] = useState<RAGResponse['replyType'] | null>(null);
const [activeReplyContent, setActiveReplyContent] = useState<RAGResponse['replyContent'] | null>(null);
// Secondary reply type for composite visualizations (e.g., map_points when primary is factual_count)
const [activeSecondaryReplyType, setActiveSecondaryReplyType] = useState<RAGResponse['secondaryReplyType'] | null>(null);
const [activeSecondaryReplyContent, setActiveSecondaryReplyContent] = useState<RAGResponse['secondaryReplyContent'] | null>(null);
// Embedding Projector state
const [showProjector, setShowProjector] = useState(false);
const [projectorSimpleMode, setProjectorSimpleMode] = useState(false);
const [projectorPinned, setProjectorPinned] = useState(false);
const [embeddingPoints, setEmbeddingPoints] = useState<EmbeddingPoint[]>([]);
const [embeddingsLoading, setEmbeddingsLoading] = useState(false);
const [highlightedSourceIndices, setHighlightedSourceIndices] = useState<number[]>([]);
const [additionalContextPoints, setAdditionalContextPoints] = useState<EmbeddingPoint[]>([]);
// Projector mode: 'global' shows all 500 points, 'context' shows only RAG results with vectors
const [projectorMode, setProjectorMode] = useState<'global' | 'context'>('global');
const [contextEmbeddingPoints, setContextEmbeddingPoints] = useState<EmbeddingPoint[]>([]);
// Load All state for embedding projector
const [isLoadingAllEmbeddings, setIsLoadingAllEmbeddings] = useState(false);
const [totalEmbeddingPoints, setTotalEmbeddingPoints] = useState<number | undefined>(undefined);
// Knowledge Graph Projector state
const [showGraphProjector, setShowGraphProjector] = useState(false);
const [graphProjectorMode, setGraphProjectorMode] = useState<'global' | 'context'>('global');
const [graphProjectorPinned, setGraphProjectorPinned] = useState(false);
const [graphBackend, setGraphBackend] = useState<GraphBackend>('oxigraph');
const [contextGraphData, setContextGraphData] = useState<GraphData | null>(null);
// Track if user has manually selected a mode (to avoid overriding their choice)
const userSelectedModeRef = useRef(false);
// Resizable visualization panel state
const [vizPanelWidth, setVizPanelWidth] = useState(400);
const [isResizing, setIsResizing] = useState(false);
// Cache management state
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);
// Data source selection state - default to qdrant + sparql (fastest combination)
const [selectedSources, setSelectedSources] = useState<DataSource[]>(['qdrant', 'sparql']);
// Infinite scroll toggle (alternative to Load More button for cards/table)
const [infiniteScrollEnabled, setInfiniteScrollEnabled] = useState(false);
// Refs
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const historyDropdownRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// ============================================================================
// Effects
// ============================================================================
// Load history from localStorage
useEffect(() => {
try {
const savedHistory = localStorage.getItem(STORAGE_KEYS.HISTORY);
if (savedHistory) {
const parsed = JSON.parse(savedHistory);
setHistory(parsed.map((h: { question: string; timestamp: string }) => ({
...h,
timestamp: new Date(h.timestamp)
})));
}
} catch (e) {
console.error('Failed to load history:', e);
}
}, []);
// Load selected sources from localStorage
useEffect(() => {
try {
const savedSources = localStorage.getItem(STORAGE_KEYS.SELECTED_SOURCES);
if (savedSources) {
const parsed = JSON.parse(savedSources);
if (Array.isArray(parsed) && parsed.length > 0) {
// Validate that all sources are valid
const validSources = parsed.filter((s: string) =>
DATA_SOURCES.some(ds => ds.id === s)
) as DataSource[];
if (validSources.length > 0) {
setSelectedSources(validSources);
}
}
}
} catch (e) {
console.error('Failed to load selected sources:', e);
}
}, []);
// Save selected sources to localStorage
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEYS.SELECTED_SOURCES, JSON.stringify(selectedSources));
} catch (e) {
console.error('Failed to save selected sources:', e);
}
}, [selectedSources]);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-resize textarea
useEffect(() => {
if (inputRef.current) {
inputRef.current.style.height = 'auto';
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 150)}px`;
}
}, [input]);
// Close dropdowns on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
setShowModelDropdown(false);
}
if (historyDropdownRef.current && !historyDropdownRef.current.contains(e.target as Node)) {
setShowHistoryDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Load embeddings when projector is shown
useEffect(() => {
if (!showProjector || !qdrantStatus.isConnected) return;
if (embeddingPoints.length > 0) return; // Already loaded
const loadEmbeddings = async () => {
setEmbeddingsLoading(true);
try {
// Load embeddings from the heritage_custodians collection
const collectionName = 'heritage_custodians';
const { points } = await scrollPoints(collectionName, 500, null);
const embeddings: EmbeddingPoint[] = points.map(p => ({
id: p.id,
vector: p.vector,
payload: p.payload,
}));
setEmbeddingPoints(embeddings);
} catch (err) {
console.error('Failed to load embeddings:', err);
} finally {
setEmbeddingsLoading(false);
}
};
loadEmbeddings();
}, [showProjector, qdrantStatus.isConnected, embeddingPoints.length, scrollPoints]);
// Track RAG source indices when context updates
useEffect(() => {
if (!lastContext?.qdrantResults || embeddingPoints.length === 0) {
setHighlightedSourceIndices([]);
return;
}
// Find indices of points that match the RAG results
const sourceIds = new Set(lastContext.qdrantResults.map(r => String(r.id)));
const indices: number[] = [];
embeddingPoints.forEach((point, index) => {
if (sourceIds.has(String(point.id))) {
indices.push(index);
}
});
setHighlightedSourceIndices(indices);
}, [lastContext?.qdrantResults, embeddingPoints]);
// Populate context embedding points from RAG results (with vectors)
useEffect(() => {
if (!lastContext?.qdrantResults) {
setContextEmbeddingPoints([]);
return;
}
// Filter for results that have vectors (from with_vector: true)
const pointsWithVectors: EmbeddingPoint[] = lastContext.qdrantResults
.filter(r => r.vector && Array.isArray(r.vector) && r.vector.length > 0)
.map(r => ({
id: r.id,
vector: r.vector as number[],
payload: r.payload,
}));
console.log(`[EmbeddingProjector] Context mode: ${pointsWithVectors.length} points with vectors from ${lastContext.qdrantResults.length} RAG results`);
setContextEmbeddingPoints(pointsWithVectors);
}, [lastContext?.qdrantResults]);
// Populate context graph data from RAG results for Knowledge Graph panel "Context" mode
// Processes Qdrant, SPARQL, and TypeDB results into a unified graph visualization
useEffect(() => {
console.log('[KnowledgeGraph DEBUG] useEffect triggered, lastContext:', {
hasLastContext: !!lastContext,
qdrantCount: lastContext?.qdrantResults?.length ?? 0,
sparqlCount: lastContext?.sparqlResults?.length ?? 0,
typedbCount: lastContext?.typedbResults?.length ?? 0,
});
const hasQdrantResults = lastContext?.qdrantResults && lastContext.qdrantResults.length > 0;
const hasSparqlResults = lastContext?.sparqlResults && lastContext.sparqlResults.length > 0;
const hasTypedbResults = lastContext?.typedbResults && lastContext.typedbResults.length > 0;
if (!hasQdrantResults && !hasSparqlResults && !hasTypedbResults) {
console.log('[KnowledgeGraph DEBUG] No results, setting contextGraphData to null');
setContextGraphData(null);
return;
}
// Transform all database results into GraphData format
// Each result becomes a node, relationships between entities become edges
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const nodeIds = new Set<string>();
// Helper to extract entity type from RDF type URI
const extractTypeFromUri = (uri: string): string => {
if (!uri) return 'Entity';
// Extract last segment from URI (e.g., "http://schema.org/Museum" -> "Museum")
const lastSlash = uri.lastIndexOf('/');
const lastHash = uri.lastIndexOf('#');
const lastSeparator = Math.max(lastSlash, lastHash);
if (lastSeparator >= 0 && lastSeparator < uri.length - 1) {
return uri.substring(lastSeparator + 1);
}
return 'Entity';
};
// Helper to extract value from SPARQL binding (handles { value: '...', type: 'uri'|'literal' } format)
const getSparqlValue = (binding: unknown): string => {
if (!binding) return '';
if (typeof binding === 'string') return binding;
if (typeof binding === 'object' && binding !== null) {
const obj = binding as Record<string, unknown>;
if ('value' in obj) return String(obj.value);
}
return String(binding);
};
// =========================================================================
// Process Qdrant results (vector similarity search)
// =========================================================================
if (hasQdrantResults) {
// Debug: Log first 3 Qdrant result payloads to see if ghcid is present
console.log('[KnowledgeGraph DEBUG] First 3 Qdrant payloads:',
lastContext!.qdrantResults.slice(0, 3).map(r => ({
id: r.id,
payloadKeys: Object.keys(r.payload || {}),
ghcid: r.payload?.ghcid,
name: r.payload?.name,
}))
);
lastContext!.qdrantResults.forEach((result, index) => {
const payload = result.payload || {};
const nodeId = `qdrant:${String(result.id)}`;
// Skip if we've already processed this node
if (nodeIds.has(nodeId)) return;
nodeIds.add(nodeId);
// Determine node type from payload
const institutionType = String(payload.institution_type || payload.type || 'unknown').toLowerCase();
const entityType = mapInstitutionTypeToEntityType(institutionType);
// Create node with available attributes
const node: GraphNode = {
id: nodeId,
label: String(payload.name || payload.custodian_name || `Qdrant Result ${index + 1}`),
type: 'custodian',
entityType: entityType,
attributes: {
...payload,
score: result.score,
source: 'qdrant',
sourceColor: '#6366f1', // Indigo for Qdrant
},
};
nodes.push(node);
// Create edges from relationships in payload
// Link to city/location if available
if (payload.city) {
const cityId = `city:${String(payload.city).toLowerCase().replace(/\s+/g, '_')}`;
if (!nodeIds.has(cityId)) {
nodeIds.add(cityId);
nodes.push({
id: cityId,
label: String(payload.city),
type: 'location',
entityType: 'Place',
attributes: {
country: payload.country,
source: 'qdrant_context',
},
});
}
edges.push({
id: `${nodeId}-located_in-${cityId}`,
source: nodeId,
target: cityId,
label: 'located_in',
relationType: 'spatial',
});
}
// Link to country if available
if (payload.country) {
const countryId = `country:${String(payload.country).toLowerCase()}`;
if (!nodeIds.has(countryId)) {
nodeIds.add(countryId);
nodes.push({
id: countryId,
label: String(payload.country),
type: 'location',
entityType: 'Country',
attributes: { source: 'qdrant_context' },
});
}
// Link city to country if city exists, otherwise link custodian to country
const cityId = payload.city ? `city:${String(payload.city).toLowerCase().replace(/\s+/g, '_')}` : null;
if (cityId && nodeIds.has(cityId)) {
const edgeId = `${cityId}-in_country-${countryId}`;
if (!edges.some(e => e.id === edgeId)) {
edges.push({
id: edgeId,
source: cityId,
target: countryId,
label: 'in_country',
relationType: 'spatial',
});
}
} else {
edges.push({
id: `${nodeId}-in_country-${countryId}`,
source: nodeId,
target: countryId,
label: 'in_country',
relationType: 'spatial',
});
}
}
});
}
// =========================================================================
// Process SPARQL results (Oxigraph knowledge graph)
// Format: Array of bindings with ?s (URI), ?label, ?type
// =========================================================================
if (hasSparqlResults) {
lastContext!.sparqlResults.forEach((binding, index) => {
// Extract values from SPARQL binding format
const subjectUri = getSparqlValue(binding.s);
const label = getSparqlValue(binding.label) || getSparqlValue(binding.name) || getSparqlValue(binding.prefLabel);
const typeUri = getSparqlValue(binding.type) || getSparqlValue(binding.class);
// Skip if no meaningful identifier
if (!subjectUri && !label) return;
// Create a unique node ID - prefer URI, fall back to generated ID
const nodeId = subjectUri ? `sparql:${subjectUri}` : `sparql:result_${index}`;
// Check for duplicates by URI or by label (for cross-source deduplication)
if (nodeIds.has(nodeId)) return;
// Also check if we have a Qdrant node with the same label (cross-source dedup)
const normalizedLabel = label.toLowerCase().trim();
const existingNode = nodes.find(n =>
n.label.toLowerCase().trim() === normalizedLabel && n.attributes?.source !== 'sparql'
);
if (existingNode) {
// Merge: add SPARQL source info to existing node
existingNode.attributes = {
...existingNode.attributes,
sparqlUri: subjectUri,
sparqlType: typeUri,
sources: ['qdrant', 'sparql'], // Multi-source attribution
};
return;
}
nodeIds.add(nodeId);
// Determine entity type from RDF type URI
const entityType = typeUri ? extractTypeFromUri(typeUri) : 'Entity';
// Create node
const node: GraphNode = {
id: nodeId,
label: label || `SPARQL Result ${index + 1}`,
type: 'custodian', // Most SPARQL results are heritage custodians
entityType: mapInstitutionTypeToEntityType(entityType.toLowerCase()) || entityType,
attributes: {
uri: subjectUri,
rdfType: typeUri,
source: 'sparql',
sourceColor: '#10b981', // Emerald for SPARQL
...binding, // Include all binding variables
},
};
nodes.push(node);
});
}
// =========================================================================
// Process TypeDB results (TypeQL knowledge graph)
// Format: Array of records with entity attributes
// =========================================================================
if (hasTypedbResults) {
lastContext!.typedbResults.forEach((record, index) => {
// TypeDB returns entities with various attribute patterns
// Common pattern: { x: {...entity...}, n: { value: 'name' } }
const entity = record.x as Record<string, unknown> | undefined;
const nameAttr = record.n as { value?: string } | undefined;
const name = nameAttr?.value || (entity as Record<string, unknown>)?.name as string || '';
// Extract entity identifier if available
const entityId = entity ? String((entity as Record<string, unknown>).iid || index) : String(index);
const nodeId = `typedb:${entityId}`;
// Skip if no meaningful data or already processed
if (!name && !entity) return;
if (nodeIds.has(nodeId)) return;
// Check for cross-source duplicates by label
const normalizedLabel = name.toLowerCase().trim();
if (normalizedLabel) {
const existingNode = nodes.find(n =>
n.label.toLowerCase().trim() === normalizedLabel && n.attributes?.source !== 'typedb'
);
if (existingNode) {
// Merge: add TypeDB source info to existing node
const existingSources = existingNode.attributes?.sources as string[] | undefined;
existingNode.attributes = {
...existingNode.attributes,
typedbId: entityId,
sources: existingSources ? [...existingSources, 'typedb'] : [existingNode.attributes?.source as string || 'unknown', 'typedb'],
};
return;
}
}
nodeIds.add(nodeId);
// Determine entity type from TypeDB entity type if available
const typedbType = String((entity as Record<string, unknown>)?.type || 'heritage_custodian');
// Create node
const node: GraphNode = {
id: nodeId,
label: name || `TypeDB Result ${index + 1}`,
type: 'custodian',
entityType: mapInstitutionTypeToEntityType(typedbType.toLowerCase()) || 'Organization',
attributes: {
...record,
source: 'typedb',
sourceColor: '#f59e0b', // Amber for TypeDB
},
};
nodes.push(node);
});
}
const graphData: GraphData = { nodes, edges };
// Log summary with breakdown by source
const qdrantCount = nodes.filter(n => n.attributes?.source === 'qdrant').length;
const sparqlCount = nodes.filter(n => n.attributes?.source === 'sparql').length;
const typedbCount = nodes.filter(n => n.attributes?.source === 'typedb').length;
const multiSourceCount = nodes.filter(n => Array.isArray(n.attributes?.sources)).length;
console.log(
`[KnowledgeGraph] Context mode: ${nodes.length} nodes (Qdrant: ${qdrantCount}, SPARQL: ${sparqlCount}, TypeDB: ${typedbCount}, Multi-source: ${multiSourceCount}), ` +
`${edges.length} edges`
);
console.log('[KnowledgeGraph DEBUG] Setting contextGraphData with:', {
nodeCount: graphData.nodes.length,
edgeCount: graphData.edges.length,
firstNodeLabel: graphData.nodes[0]?.label,
// Debug: Check GHCID presence in first 5 Qdrant nodes
first5QdrantNodesGhcid: graphData.nodes
.filter(n => n.attributes?.source === 'qdrant')
.slice(0, 5)
.map(n => ({
label: n.label,
ghcid: n.attributes?.ghcid,
ghcid_current: n.attributes?.ghcid_current,
hasGhcidField: 'ghcid' in (n.attributes || {}),
attrKeys: Object.keys(n.attributes || {}),
})),
});
setContextGraphData(graphData);
}, [lastContext?.qdrantResults, lastContext?.sparqlResults, lastContext?.typedbResults]);
// Auto-switch to Context mode when RAG results become available
// This improves UX by showing retrieved entities immediately when user opens the KG panel
useEffect(() => {
// Only auto-switch if:
// 1. Context data is available (has nodes)
// 2. We're currently in global mode (avoid switching back if user manually selected global)
// 3. The panel is visible (showGraphProjector)
// 4. User hasn't manually selected a mode in this session
if (
contextGraphData &&
contextGraphData.nodes.length > 0 &&
graphProjectorMode === 'global' &&
showGraphProjector &&
!userSelectedModeRef.current
) {
console.log('[KnowledgeGraph] Auto-switching to context mode (context data available with', contextGraphData.nodes.length, 'nodes)');
setGraphProjectorMode('context');
}
}, [contextGraphData, graphProjectorMode, showGraphProjector]);
// Reset the manual mode selection flag when panel is closed
useEffect(() => {
if (!showGraphProjector) {
userSelectedModeRef.current = false;
}
}, [showGraphProjector]);
// Wrapper for user-initiated mode changes (marks manual selection)
const handleGraphProjectorModeChange = useCallback((mode: 'global' | 'context') => {
userSelectedModeRef.current = true;
setGraphProjectorMode(mode);
}, []);
// Helper function to map institution types to entity types for graph coloring
function mapInstitutionTypeToEntityType(type: string): string {
const typeMap: Record<string, string> = {
'museum': 'Museum',
'library': 'Library',
'archive': 'Archive',
'gallery': 'Gallery',
'collection': 'Collection',
'm': 'Museum',
'l': 'Library',
'a': 'Archive',
'g': 'Gallery',
'r': 'Research',
'o': 'Organization',
'c': 'Corporation',
'u': 'Unknown',
'b': 'Botanical',
'e': 'Education',
's': 'Society',
'f': 'Feature',
'i': 'Intangible',
'x': 'Mixed',
'p': 'Personal',
'h': 'HolySite',
'd': 'Digital',
'n': 'NGO',
't': 'TasteSmell',
};
return typeMap[type.toLowerCase()] || 'Organization';
}
// Load ALL embeddings from Qdrant (for comprehensive search)
const loadAllEmbeddings = useCallback(async () => {
if (!qdrantStatus.isConnected || isLoadingAllEmbeddings) return;
setIsLoadingAllEmbeddings(true);
try {
const collectionName = 'heritage_custodians';
const allPoints: EmbeddingPoint[] = [];
let offset: string | number | null = null;
// Scroll through all points in batches
while (true) {
const { points, nextOffset } = await scrollPoints(collectionName, 500, offset);
const batch: EmbeddingPoint[] = points.map(p => ({
id: p.id,
vector: p.vector,
payload: p.payload,
}));
allPoints.push(...batch);
if (!nextOffset || points.length === 0) break;
offset = nextOffset;
}
setEmbeddingPoints(allPoints);
setTotalEmbeddingPoints(allPoints.length);
console.log(`[EmbeddingProjector] Loaded all ${allPoints.length} points`);
} catch (err) {
console.error('Failed to load all embeddings:', err);
} finally {
setIsLoadingAllEmbeddings(false);
}
}, [qdrantStatus.isConnected, isLoadingAllEmbeddings, scrollPoints]);
// Handle panel resize with mouse
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
const newWidth = window.innerWidth - e.clientX;
// Constrain width between 280px and 800px
setVizPanelWidth(Math.min(800, Math.max(280, newWidth)));
};
const handleMouseUp = () => {
setIsResizing(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing]);
// Load cache stats when panel is opened
useEffect(() => {
if (showCachePanel) {
getCacheStats().then(setCacheStats).catch(console.error);
}
}, [showCachePanel, getCacheStats]);
// ============================================================================
// Handlers
// ============================================================================
const showNotification = useCallback((message: string) => {
setNotification(message);
setTimeout(() => setNotification(null), 3000);
}, []);
// Cache handlers
const handleToggleCache = useCallback(() => {
setCacheEnabled(!cacheEnabled);
showNotification(cacheEnabled ? t('cacheDisabled') : t('cacheEnabled'));
}, [cacheEnabled, setCacheEnabled, showNotification, t]);
const handleClearCache = useCallback(async () => {
try {
const result = await clearCache();
const stats = await getCacheStats();
setCacheStats(stats);
// 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]);
const handleThresholdChange = useCallback((value: number) => {
setCacheSimilarityThresholdLocal(value);
setCacheSimilarityThreshold(value);
}, [setCacheSimilarityThreshold]);
// Toggle data source selection
const toggleSource = useCallback((source: DataSource) => {
setSelectedSources(prev => {
if (prev.includes(source)) {
// Don't allow deselecting the last source
if (prev.length === 1) {
showNotification(t('noSourcesSelected'));
return prev;
}
return prev.filter(s => s !== source);
} else {
return [...prev, source];
}
});
}, [showNotification, t]);
const saveHistory = useCallback((items: Array<{ question: string; timestamp: Date }>) => {
try {
const trimmed = items.slice(-20);
localStorage.setItem(STORAGE_KEYS.HISTORY, JSON.stringify(trimmed));
setHistory(trimmed);
} catch (e) {
console.error('Failed to save history:', e);
}
}, []);
const handleSend = async () => {
const question = input.trim();
if (!question || ragLoading) return;
const userMessage: ConversationMessage = {
id: `msg-${Date.now()}`,
role: 'user',
content: question,
timestamp: new Date(),
};
const loadingMessage: ConversationMessage = {
id: `msg-${Date.now()}-loading`,
role: 'assistant',
content: t('searching'),
timestamp: new Date(),
isLoading: true,
};
setMessages(prev => [...prev, userMessage, loadingMessage]);
setInput('');
try {
const response = await queryRAG(question, {
llmProvider: uiState.llmProvider,
language,
conversationHistory: messages,
bypassCache: bypassCache,
// Data source selection - qdrant is always included, toggle sparql/typedb
includeSparql: selectedSources.includes('sparql'),
includeTypeDB: selectedSources.includes('typedb'),
});
// Reset bypass cache after query (it's a one-shot toggle)
if (bypassCache) {
setBypassCache(false);
}
// Save to history
saveHistory([...history, { question, timestamp: new Date() }]);
// Update visualization
if (response.visualizationType && response.visualizationType !== 'none') {
setActiveVizType(response.visualizationType);
setActiveVizData(response.visualizationData || null);
}
// Store retrieved results for social network visualization
console.log('[ConversationPage] response.retrievedResults:', response.retrievedResults);
console.log('[ConversationPage] response.queryType:', response.queryType);
if (response.retrievedResults && response.retrievedResults.length > 0) {
console.log('[ConversationPage] Setting activeRetrievedResults with', response.retrievedResults.length, 'items');
setActiveRetrievedResults(response.retrievedResults);
setActiveQueryType(response.queryType || null);
// Auto-switch to network view for person queries
if (response.queryType === 'person') {
console.log('[ConversationPage] Auto-switching to network view (person query)');
setActiveVizType('network');
}
} else {
console.log('[ConversationPage] No retrievedResults or empty array');
}
// Set reply type classification from backend classify_and_format()
console.log('[ConversationPage] response.replyType:', response.replyType);
console.log('[ConversationPage] response.replyContent:', response.replyContent);
console.log('[ConversationPage] response.secondaryReplyType:', response.secondaryReplyType);
console.log('[ConversationPage] response.secondaryReplyContent:', response.secondaryReplyContent);
setActiveReplyType(response.replyType || null);
setActiveReplyContent(response.replyContent || null);
setActiveSecondaryReplyType(response.secondaryReplyType || null);
setActiveSecondaryReplyContent(response.secondaryReplyContent || null);
// Capture cache lookup result (updated by queryRAG)
const wasCacheHit = lastCacheLookup?.found || false;
const cacheSimilarity = lastCacheLookup?.similarity;
const cacheMethod = lastCacheLookup?.method;
const cacheTier = lastCacheLookup?.tier;
// Rule 46: Prioritize backend epistemic provenance over cache-only provenance
// Backend provenance includes full derivation chain; cache provenance is only for cache hits
const epistemicProvenance = response.epistemicProvenance || lastCacheLookup?.entry?.epistemicProvenance;
const cacheLookupTimeMs = lastCacheLookup?.lookupTimeMs;
// Replace loading message with response
setMessages(prev => prev.map(msg =>
msg.id === loadingMessage.id
? {
...msg,
content: response.answer,
response,
isLoading: false,
fromCache: wasCacheHit,
cacheSimilarity: cacheSimilarity,
cacheMethod: cacheMethod,
cacheTier: cacheTier,
epistemicProvenance: epistemicProvenance,
cacheLookupTimeMs: cacheLookupTimeMs,
}
: msg
));
} catch (error) {
setMessages(prev => prev.map(msg =>
msg.id === loadingMessage.id
? {
...msg,
content: t('errorConnection'),
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
: msg
));
}
};
// 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, {
llmProvider: uiState.llmProvider,
language,
conversationHistory: historyForContext,
bypassCache: true, // Always bypass cache for refresh
// Data source selection - use current settings
includeSparql: selectedSources.includes('sparql'),
includeTypeDB: selectedSources.includes('typedb'),
});
// 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');
}
}
// Set reply type classification from backend classify_and_format()
setActiveReplyType(response.replyType || null);
setActiveReplyContent(response.replyContent || null);
setActiveSecondaryReplyType(response.secondaryReplyType || null);
setActiveSecondaryReplyContent(response.secondaryReplyContent || null);
// 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();
handleSend();
}
};
const handleExampleClick = (question: string) => {
setInput(question);
inputRef.current?.focus();
};
const handleCopyQuery = async (id: string, query: string) => {
await navigator.clipboard.writeText(query);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const handleHistoryClick = (item: { question: string }) => {
setInput(item.question);
setShowHistoryDropdown(false);
inputRef.current?.focus();
};
const handleClearHistory = () => {
localStorage.removeItem(STORAGE_KEYS.HISTORY);
setHistory([]);
setShowHistoryDropdown(false);
};
const handleExportConversation = () => {
if (messages.length === 0) return;
const exportData = {
version: 1,
exportedAt: new Date().toISOString(),
llmProvider: uiState.llmProvider,
messages: messages.map(m => ({
role: m.role,
content: m.content,
timestamp: m.timestamp,
response: m.response,
})),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `conversation-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
showNotification(t('exportSuccess'));
};
const handleImportConversation = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (data.version && data.messages && Array.isArray(data.messages)) {
const importedMessages: ConversationMessage[] = data.messages.map(
(m: ConversationMessage, i: number) => ({
id: `imported-${Date.now()}-${i}`,
role: m.role,
content: m.content,
timestamp: new Date(m.timestamp),
response: m.response,
})
);
setMessages(importedMessages);
if (data.llmModel) {
setLLMModel(data.llmModel);
}
showNotification(t('importSuccess'));
} else {
showNotification(t('importError'));
}
} catch {
showNotification(t('importError'));
}
};
reader.readAsText(file);
e.target.value = '';
};
const handleClearConversation = () => {
setMessages([]);
clearContext();
setActiveVizData(null);
setActiveRetrievedResults(null);
setActiveQueryType(null);
setHighlightedSourceIndices([]);
setAdditionalContextPoints([]);
showNotification(t('conversationCleared'));
};
// Handler for loading more results and updating visualization data
const handleLoadMoreResults = useCallback(async () => {
const newResults = await loadMoreResults();
if (newResults.length > 0) {
// For person queries, update activeRetrievedResults for social network graph
if (activeQueryType === 'person') {
const newRetrievedResults: RetrievedResult[] = newResults.map((result) => ({
type: 'person' as const,
person_id: String(result.id),
name: (result.payload?.name as string) || 'Unknown',
headline: result.payload?.headline as string | undefined,
custodian_name: result.payload?.custodian_name as string | undefined,
custodian_slug: result.payload?.custodian_slug as string | undefined,
heritage_relevant: result.payload?.heritage_relevant as boolean | undefined,
heritage_type: result.payload?.heritage_type as string | undefined,
linkedin_url: result.payload?.linkedin_url as string | null | undefined,
score: result.score,
}));
// Combine with existing results (avoiding duplicates by person_id)
setActiveRetrievedResults(prev => {
if (!prev) return newRetrievedResults;
const existingIds = new Set(prev.map(r => r.person_id));
const uniqueNew = newRetrievedResults.filter(r => !existingIds.has(r.person_id));
console.log(`[ConversationPage] Adding ${uniqueNew.length} new person results (${newRetrievedResults.length - uniqueNew.length} duplicates filtered)`);
return [...prev, ...uniqueNew];
});
}
// For institution queries (or any query), update activeVizData for map/table/cards
if (activeVizData) {
// Convert Qdrant results to InstitutionData format
const newInstitutions: InstitutionData[] = newResults.map((result) => ({
id: String(result.id),
name: (result.payload?.name as string) || (result.payload?.title as string) || 'Unknown',
type: (result.payload?.institution_type as string) || (result.payload?.type as string),
city: (result.payload?.city as string) || (result.payload?.municipality as string),
province: (result.payload?.province as string) || (result.payload?.admin1 as string),
country: (result.payload?.country as string),
latitude: result.payload?.latitude as number,
longitude: result.payload?.longitude as number,
website: (result.payload?.website as string),
description: (result.payload?.description as string),
rating: result.payload?.rating as number,
ghcid: (result.payload?.ghcid as string),
}));
// Combine with existing institutions (avoiding duplicates by id)
const existingIds = new Set(activeVizData.institutions?.map(i => i.id) || []);
const uniqueNewInstitutions = newInstitutions.filter(i => !existingIds.has(i.id));
setActiveVizData(prevData => {
if (!prevData) return prevData;
return {
...prevData,
type: prevData.type, // Ensure type is explicitly set (required field)
institutions: [...(prevData.institutions || []), ...uniqueNewInstitutions],
};
});
}
}
}, [loadMoreResults, activeVizData, activeQueryType]);
// Handler for adding point to context from Embedding Projector
const handleContextSelect = useCallback((point: EmbeddingPoint) => {
// Check if already in additional context
const alreadyAdded = additionalContextPoints.some(p => String(p.id) === String(point.id));
if (alreadyAdded) return;
// Add to additional context
setAdditionalContextPoints(prev => [...prev, point]);
// Also add to highlighted indices if not already there
const pointIndex = embeddingPoints.findIndex(p => String(p.id) === String(point.id));
if (pointIndex !== -1 && !highlightedSourceIndices.includes(pointIndex)) {
setHighlightedSourceIndices(prev => [...prev, pointIndex]);
}
// Show notification
const pointName = (point.payload?.name as string) || (point.payload?.title as string) || `Point ${point.id}`;
showNotification(`${t('pointAddedToContext')}: ${pointName}`);
}, [additionalContextPoints, embeddingPoints, highlightedSourceIndices, showNotification, t]);
// ============================================================================
// Render
// ============================================================================
return (
<div className="conversation-page">
{/* Notification Toast */}
{notification && (
<div className="conversation-notification">
<span>{notification}</span>
<button onClick={() => setNotification(null)} className="conversation-notification__close">
<X size={14} />
</button>
</div>
)}
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
accept=".json"
onChange={handleImportConversation}
style={{ display: 'none' }}
/>
{/* Main Layout */}
<div className={`conversation-layout ${activeVizData ? 'conversation-layout--with-viz' : ''}`}>
{/* Chat Panel */}
<div className="conversation-chat">
{/* Header */}
<div className="conversation-chat__header">
<div className="conversation-chat__title">
<Sparkles size={24} className="conversation-chat__icon" />
<div>
<h1>{t('pageTitle')}</h1>
<p>{t('pageSubtitle')}</p>
</div>
</div>
<button
className="conversation-chat__new-btn"
onClick={handleClearConversation}
title={t('new')}
type="button"
>
<Plus size={18} />
<span>{t('new')}</span>
</button>
</div>
{/* Input Area - Top */}
<div className="conversation-chat__input-area">
<div className="conversation-chat__input-container">
<textarea
ref={inputRef}
className="conversation-chat__input"
placeholder={t('placeholder')}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={ragLoading}
rows={1}
/>
<button
className="conversation-chat__send-btn"
onClick={handleSend}
disabled={!input.trim() || ragLoading}
title={t('send')}
>
{ragLoading ? (
<>
<Loader2 className="conversation-chat__send-icon conversation-chat__send-icon--loading" size={18} />
<span>{t('thinking')}</span>
</>
) : (
<>
<Send className="conversation-chat__send-icon" size={18} />
<span>{t('send')}</span>
</>
)}
</button>
</div>
<div className="conversation-chat__toolbar">
{/* LLM Provider Selector */}
<div className="conversation-chat__model-selector" ref={modelDropdownRef}>
<button
className="conversation-chat__model-btn"
onClick={() => setShowModelDropdown(!showModelDropdown)}
type="button"
style={{ '--provider-color': LLM_PROVIDER_CONFIG[LLM_MODELS.find(m => m.id === uiState.llmModel)?.provider || 'zai']?.color } as React.CSSProperties}
>
<img
src={LLM_PROVIDER_CONFIG[LLM_MODELS.find(m => m.id === uiState.llmModel)?.provider || 'zai']?.logo}
alt=""
className="conversation-chat__provider-logo"
/>
<span className="conversation-chat__model-name">
{LLM_MODELS.find(m => m.id === uiState.llmModel)?.name || 'GLM-4.7'}
</span>
<span className="conversation-chat__provider-badge">
{LLM_MODELS.find(m => m.id === uiState.llmModel)?.tier === 'free' ? '🆓' : '💎'}
</span>
<ChevronDown
size={14}
className={`conversation-chat__model-chevron ${showModelDropdown ? 'conversation-chat__model-chevron--open' : ''}`}
/>
</button>
{showModelDropdown && (
<div className="conversation-chat__model-dropdown">
{LLM_MODELS.map(model => (
<button
key={model.id}
className={`conversation-chat__model-option ${uiState.llmModel === model.id ? 'conversation-chat__model-option--selected' : ''}`}
onClick={() => {
setLLMModel(model.id);
setShowModelDropdown(false);
}}
style={{ '--provider-color': LLM_PROVIDER_CONFIG[model.provider]?.color } as React.CSSProperties}
>
<img
src={LLM_PROVIDER_CONFIG[model.provider]?.logo}
alt=""
className="conversation-chat__provider-logo"
/>
<span className="conversation-chat__model-option-name">
{model.name} {model.tier === 'free' ? '🆓' : '💎'}
</span>
<span className="conversation-chat__model-option-desc">{model.description}</span>
</button>
))}
</div>
)}
</div>
{/* Data Source Toggles */}
<div className="conversation-chat__sources">
<span className="conversation-chat__sources-label">{t('dataSources')}:</span>
<div className="conversation-chat__sources-toggles">
{DATA_SOURCES.map(source => {
const Icon = source.icon;
const isSelected = selectedSources.includes(source.id);
return (
<button
key={source.id}
className={`conversation-chat__source-toggle ${isSelected ? 'conversation-chat__source-toggle--active' : ''}`}
onClick={() => toggleSource(source.id)}
title={source.description[language]}
style={{
'--source-color': source.color,
borderColor: isSelected ? source.color : undefined,
backgroundColor: isSelected ? `${source.color}15` : undefined,
} as React.CSSProperties}
type="button"
>
<Icon size={14} style={{ color: isSelected ? source.color : undefined }} />
<span>{source.name[language]}</span>
</button>
);
})}
</div>
</div>
<div className="conversation-chat__actions">
{/* History */}
<div className="conversation-chat__history-selector" ref={historyDropdownRef}>
<button
className="conversation-chat__action-btn"
onClick={() => setShowHistoryDropdown(!showHistoryDropdown)}
title={t('history')}
type="button"
>
<History size={16} />
<span>{t('history')}</span>
</button>
{showHistoryDropdown && (
<div className="conversation-chat__history-dropdown">
<div className="conversation-chat__history-header">
<span>{t('history')}</span>
{history.length > 0 && (
<button
className="conversation-chat__history-clear"
onClick={handleClearHistory}
>
{t('clearHistory')}
</button>
)}
</div>
{history.length === 0 ? (
<div className="conversation-chat__history-empty">{t('noHistory')}</div>
) : (
<div className="conversation-chat__history-list">
{history.slice().reverse().map((item, i) => (
<button
key={i}
className="conversation-chat__history-item"
onClick={() => handleHistoryClick(item)}
>
{item.question}
</button>
))}
</div>
)}
</div>
)}
</div>
{/* Export */}
<button
className="conversation-chat__action-btn"
onClick={handleExportConversation}
title={t('exportConversation')}
disabled={messages.length === 0}
type="button"
>
<Download size={16} />
<span>{t('export')}</span>
</button>
{/* Import */}
<button
className="conversation-chat__action-btn"
onClick={() => fileInputRef.current?.click()}
title={t('importConversation')}
type="button"
>
<Upload size={16} />
<span>{t('import')}</span>
</button>
{/* Clear */}
{messages.length > 0 && (
<button
className="conversation-chat__action-btn conversation-chat__action-btn--danger"
onClick={handleClearConversation}
title={t('clearConversation')}
type="button"
>
<Trash2 size={16} />
<span>{t('clear')}</span>
</button>
)}
{/* Reset Cache - Warning Style */}
<button
className="conversation-chat__action-btn conversation-chat__action-btn--warning"
onClick={handleClearCache}
title={t('clearCache')}
type="button"
>
<RefreshCw size={16} />
<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'}`}>
<Zap size={14} className="conversation-cache-status__icon" />
<span className="conversation-cache-status__text">
{lastCacheLookup.found ? t('cacheHit') : t('cacheMiss')}
</span>
{lastCacheLookup.found && lastCacheLookup.similarity && (
<span className="conversation-cache-status__similarity">
{Math.round(lastCacheLookup.similarity * 100)}%
</span>
)}
</div>
)}
{/* Cache Settings Button */}
<button
className={`conversation-chat__action-btn ${cacheEnabled ? 'conversation-chat__action-btn--active' : ''}`}
onClick={() => setShowCachePanel(true)}
title={t('cacheSettings')}
type="button"
>
<Database size={16} />
<span>{t('cache')}</span>
</button>
{/* Embedding Projector Toggle */}
<button
className={`conversation-chat__action-btn ${showProjector ? 'conversation-chat__action-btn--active' : ''}`}
onClick={() => setShowProjector(!showProjector)}
title={showProjector ? t('hideEmbeddings') : t('showEmbeddings')}
type="button"
>
<Layers size={16} />
<span>{t('embeddings')}</span>
</button>
{/* Knowledge Graph Projector Toggle */}
<button
className={`conversation-chat__action-btn ${showGraphProjector ? 'conversation-chat__action-btn--active' : ''}`}
onClick={() => setShowGraphProjector(!showGraphProjector)}
title={showGraphProjector ? t('hideGraph') : t('showGraph')}
type="button"
>
<Network size={16} />
<span>{t('graph')}</span>
</button>
</div>
<div className="conversation-chat__powered-by">
<Sparkles size={12} />
<span>DSPy + {selectedSources.map(s =>
DATA_SOURCES.find(d => d.id === s)?.name[language]
).join(' + ')}</span>
</div>
</div>
</div>
{/* Messages */}
<div className="conversation-chat__messages" ref={messagesContainerRef}>
{messages.length === 0 ? (
<div className="conversation-chat__welcome">
<div className="conversation-chat__welcome-header">
<Sparkles size={32} className="conversation-chat__welcome-icon" />
<h2>{t('welcomeTitle')}</h2>
</div>
<p className="conversation-chat__welcome-description">{t('welcomeDescription')}</p>
<div className="conversation-chat__examples">
<span className="conversation-chat__examples-title">{t('exampleQuestions')}</span>
<div className="conversation-chat__examples-list">
{EXAMPLE_QUESTIONS[language].map((question, idx) => (
<button
key={idx}
className="conversation-chat__example-btn"
onClick={() => handleExampleClick(question)}
>
{question}
</button>
))}
</div>
</div>
</div>
) : (
<>
{messages.map(message => (
<div
key={message.id}
className={`conversation-message conversation-message--${message.role}`}
>
<div className="conversation-message__content">
{message.isLoading ? (
<LoadingStages progress={retrievalProgress} language={language} />
) : isErrorMessage(message) ? (
<div className="conversation-message__error">
<div className="conversation-message__error-content">
<AlertCircle size={16} />
<span>{message.content}</span>
</div>
{/* Retry button for error messages - clears cache and retries */}
{message.role === 'assistant' && (
<button
className="conversation-message__retry-btn"
onClick={() => handleRefreshMessage(message.id)}
disabled={ragLoading || refreshingMessageId === message.id}
title={t('clearCacheAndRetry')}
>
<RefreshCw size={14} className={refreshingMessageId === message.id ? 'conversation-message__refresh-icon--spinning' : ''} />
{refreshingMessageId === message.id ? t('retryingQuery') : t('retryQuery')}
</button>
)}
</div>
) : (
<>
<p className="conversation-message__text">{message.content}</p>
{/* Query boxes */}
{message.response?.sparqlQuery && (
<div className="conversation-message__query-box">
<div className="conversation-message__query-header">
<span>{t('sparqlQuery')}</span>
<button
className="conversation-message__query-copy"
onClick={() => handleCopyQuery(`sparql-${message.id}`, message.response!.sparqlQuery!)}
>
{copiedId === `sparql-${message.id}` ? (
<><Check size={14} /> {t('copied')}</>
) : (
<><Copy size={14} /> {t('copyQuery')}</>
)}
</button>
</div>
<pre className="conversation-message__query-code">
<code>{message.response.sparqlQuery}</code>
</pre>
</div>
)}
{/* Confidence indicator */}
{message.response && (
<div className="conversation-message__meta">
{(message as ConversationMessage & { fromCache?: boolean }).fromCache && (() => {
const extMsg = message as ConversationMessage & {
fromCache?: boolean;
cacheSimilarity?: number;
cacheMethod?: 'semantic' | 'fuzzy' | 'exact' | 'none';
cacheTier?: 'local' | 'shared';
epistemicProvenance?: EpistemicProvenance;
cacheLookupTimeMs?: number;
};
// Map 'none' to 'semantic' since we only show tooltip on hits
const method = extMsg.cacheMethod === 'none' ? 'semantic' : (extMsg.cacheMethod || 'semantic');
return (
<ProvenanceTooltip
cacheTier={extMsg.cacheTier || 'local'}
cacheMethod={method}
cacheSimilarity={extMsg.cacheSimilarity || 0}
lookupTimeMs={extMsg.cacheLookupTimeMs || 0}
epistemicProvenance={extMsg.epistemicProvenance}
language={language}
onRevalidate={() => handleRefreshMessage(message.id)}
isRevalidating={refreshingMessageId === message.id}
>
<span className="conversation-message__cache-badge">
<Zap size={12} />
{t('fromCache')}
{extMsg.cacheSimilarity && (
<span> ({Math.round((extMsg.cacheSimilarity || 0) * 100)}%)</span>
)}
</span>
</ProvenanceTooltip>
);
})()}
{/* 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>
{message.response.context && (
<span className="conversation-message__results">
{message.response.context.totalRetrieved} {t('resultsFound')}
</span>
)}
</div>
)}
</>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
</div>
{/* Visualization Panel - Only show when we have data */}
{activeVizData && (
<div
className={`conversation-viz-panel conversation-viz-panel--animated ${vizExpanded ? 'conversation-viz-panel--expanded' : ''} ${isResizing ? 'conversation-viz-panel--resizing' : ''}`}
style={{ width: vizExpanded ? '100%' : `${vizPanelWidth}px` }}
>
{/* Resize handle */}
<div
className="conversation-viz-panel__resize-handle"
onMouseDown={() => setIsResizing(true)}
title={t('resizePanel') || 'Resize panel'}
/>
<VisualizationPanel
type={activeVizType}
data={activeVizData ?? undefined}
language={language}
isExpanded={vizExpanded}
onToggleExpand={() => setVizExpanded(!vizExpanded)}
onChangeType={setActiveVizType}
retrievedResults={activeRetrievedResults}
queryType={activeQueryType}
paginationState={paginationState}
isLoadingMore={isLoadingMore}
onLoadMore={handleLoadMoreResults}
infiniteScrollEnabled={infiniteScrollEnabled}
onToggleInfiniteScroll={() => setInfiniteScrollEnabled(prev => !prev)}
replyType={activeReplyType}
replyContent={activeReplyContent}
secondaryReplyType={activeSecondaryReplyType}
secondaryReplyContent={activeSecondaryReplyContent}
/>
</div>
)}
{/* Embedding Projector Panel - Draggable */}
{showProjector && (
<ConversationEmbeddingPanel
points={projectorMode === 'context' ? contextEmbeddingPoints : embeddingPoints}
isLoading={embeddingsLoading}
highlightedIndices={highlightedSourceIndices}
onClose={() => setShowProjector(false)}
title={t('embeddingProjector')}
t={t as (key: string) => string}
language={language}
simpleMode={projectorSimpleMode}
isPinned={projectorPinned}
onTogglePin={() => setProjectorPinned(prev => !prev)}
onToggleSimpleMode={() => setProjectorSimpleMode(prev => !prev)}
onContextSelect={handleContextSelect}
mode={projectorMode}
onModeChange={setProjectorMode}
contextPointsCount={contextEmbeddingPoints.length}
totalPointsCount={totalEmbeddingPoints}
onLoadAll={loadAllEmbeddings}
isLoadingAll={isLoadingAllEmbeddings}
/>
)}
{/* Knowledge Graph Panel - Draggable */}
{showGraphProjector && (
<ConversationKnowledgeGraphPanel
onClose={() => setShowGraphProjector(false)}
t={t as (key: string) => string}
language={language}
isPinned={graphProjectorPinned}
onTogglePin={() => setGraphProjectorPinned(prev => !prev)}
mode={graphProjectorMode}
onModeChange={handleGraphProjectorModeChange}
contextNodesCount={contextGraphData?.nodes.length || 0}
backend={graphBackend}
onBackendChange={setGraphBackend}
contextData={contextGraphData}
/>
)}
</div>
{/* Cache Settings Panel Modal */}
{showCachePanel && (
<>
<div
className="conversation-cache-panel__backdrop"
onClick={() => setShowCachePanel(false)}
/>
<div className="conversation-cache-panel">
<div className="conversation-cache-panel__header">
<h3 className="conversation-cache-panel__title">
<Database size={18} />
{t('cacheSettings')}
</h3>
<button
className="conversation-cache-panel__close-btn"
onClick={() => setShowCachePanel(false)}
title="Close"
>
<X size={16} />
</button>
</div>
<div className="conversation-cache-panel__content">
{/* Cache Statistics */}
{cacheStats && (
<div className="conversation-cache-panel__stats">
<div className="conversation-cache-panel__stat">
<span className="conversation-cache-panel__stat-label">{t('cacheHits')}</span>
<span className="conversation-cache-panel__stat-value conversation-cache-panel__stat-value--success">
{cacheStats.totalHits}
</span>
</div>
<div className="conversation-cache-panel__stat">
<span className="conversation-cache-panel__stat-label">{t('cacheMisses')}</span>
<span className="conversation-cache-panel__stat-value">
{cacheStats.totalMisses}
</span>
</div>
<div className="conversation-cache-panel__stat">
<span className="conversation-cache-panel__stat-label">{t('cacheHitRate')}</span>
<span className={`conversation-cache-panel__stat-value ${
cacheStats.hitRate >= 0.5 ? 'conversation-cache-panel__stat-value--success' :
cacheStats.hitRate >= 0.2 ? 'conversation-cache-panel__stat-value--warning' : ''
}`}>
{Math.round(cacheStats.hitRate * 100)}%
</span>
</div>
<div className="conversation-cache-panel__stat">
<span className="conversation-cache-panel__stat-label">{t('cacheEntries')}</span>
<span className="conversation-cache-panel__stat-value">
{cacheStats.totalEntries}
</span>
<span className="conversation-cache-panel__stat-subtext">
/ 500 max
</span>
</div>
<div className="conversation-cache-panel__stat">
<span className="conversation-cache-panel__stat-label">{t('cacheStorageUsed')}</span>
<span className="conversation-cache-panel__stat-value">
{(cacheStats.storageUsedBytes / (1024 * 1024)).toFixed(2)} MB
</span>
</div>
</div>
)}
{/* Settings */}
<div className="conversation-cache-panel__settings">
{/* Enable/Disable Toggle */}
<div className="conversation-cache-panel__setting">
<div className="conversation-cache-panel__setting-info">
<span className="conversation-cache-panel__setting-label">{t('enableCache')}</span>
<span className="conversation-cache-panel__setting-description">{t('enableCacheDescription')}</span>
</div>
<button
className={`conversation-cache-panel__toggle ${cacheEnabled ? 'conversation-cache-panel__toggle--active' : ''}`}
onClick={handleToggleCache}
type="button"
>
<span className="conversation-cache-panel__toggle-knob" />
</button>
</div>
{/* Similarity Threshold Slider */}
<div className="conversation-cache-panel__setting">
<div className="conversation-cache-panel__setting-info">
<span className="conversation-cache-panel__setting-label">{t('cacheThreshold')}</span>
<span className="conversation-cache-panel__setting-description">{t('cacheThresholdDescription')}</span>
</div>
<div className="conversation-cache-panel__slider-container">
<input
type="range"
className="conversation-cache-panel__slider"
min="0.7"
max="0.99"
step="0.01"
value={cacheSimilarityThreshold}
onChange={(e) => handleThresholdChange(parseFloat(e.target.value))}
/>
<span className="conversation-cache-panel__slider-value">
{Math.round(cacheSimilarityThreshold * 100)}%
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="conversation-cache-panel__actions">
<button
className="conversation-cache-panel__action-btn conversation-cache-panel__action-btn--danger"
onClick={handleClearCache}
disabled={!cacheStats || cacheStats.totalEntries === 0}
type="button"
>
<Trash2 size={14} />
{t('clearCache')}
</button>
<button
className="conversation-cache-panel__action-btn conversation-cache-panel__action-btn--primary"
onClick={() => setShowCachePanel(false)}
type="button"
>
<Check size={14} />
OK
</button>
</div>
</div>
</div>
</>
)}
</div>
);
};
export default ConversationPage;