/** * 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 = { none: null, map: , chart: , timeline: , network: , table: , card: , gallery: , }; // Visualization type short labels for buttons const VIZ_LABELS: Record = { 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 = { 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 = ({ 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 (
{language === 'nl' ? 'Initialiseren...' : 'Initializing...'}
); } return (
{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 (
{label.icon} {language === 'nl' ? label.nl : label.en} {isActive && } {isComplete && stageStatus.count !== undefined && stageStatus.count > 0 && ( {stageStatus.count} )} {isComplete && stageStatus.duration !== undefined && ( {stageStatus.duration}ms )} {isSkipped && ( ({language === 'nl' ? 'overgeslagen' : 'skipped'}) )} {isError && ⚠️}
); })} {/* Show detected place if available */} {detectedPlace && (
📍 {language === 'nl' ? 'Locatie' : 'Location'}: {detectedPlace}
)} {/* Show cache hit notification */} {cacheHit && (
{language === 'nl' ? 'Uit cache geladen!' : 'Loaded from cache!'} {cacheSimilarity && ( ({Math.round(cacheSimilarity * 100)}%) )}
)} {/* Show elapsed time for complete state */} {totalElapsed !== undefined && totalElapsed > 0 && currentStage === 'complete' && (
{language === 'nl' ? 'Totaal' : 'Total'}: {(totalElapsed / 1000).toFixed(1)}s
)}
); }; // ============================================================================ // Sub-Components // ============================================================================ interface InstitutionCardProps { institution: InstitutionData; language: 'nl' | 'en'; } const InstitutionCard: React.FC = ({ institution, language }) => { const t = (key: keyof typeof TEXT) => TEXT[key][language]; const navigate = useNavigate(); return (

{institution.name}

{institution.type && ( {institution.type} )}
{institution.description && (

{institution.description.slice(0, 150)} {institution.description.length > 150 ? '...' : ''}

)}
{institution.city && (
{institution.city}{institution.province ? `, ${institution.province}` : ''}
)} {(institution.rating ?? 0) > 0 && (
{'★'.repeat(Math.round(institution.rating ?? 0))} {'☆'.repeat(5 - Math.round(institution.rating ?? 0))} {(institution.rating ?? 0).toFixed(1)} {(institution.reviews ?? 0) > 0 && ( ({institution.reviews} reviews) )}
)}
{institution.website && ( {t('openWebsite')} )} {(institution.latitude && institution.longitude) && ( )}
{(institution.isil || institution.wikidata) && (
{institution.isil && ( ISIL: {institution.isil} )} {institution.wikidata && ( Wikidata: {institution.wikidata} )}
)}
); }; 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 = ({ 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(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 = {}; const byProvince: Record = {}; 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 (

{t('noVisualization')}

); } const renderVisualization = () => { switch (type) { case 'map': return (
{mapCoordinates.length > 0 ? ( <> 50} /> {/* Map pagination status bar */} {paginationState && (
{mapCoordinates.length} {language === 'nl' ? 'op kaart' : 'on map'} · {paginationState.totalLoaded} {t('ofResults')} {paginationState.estimatedTotal ?? '?'} {paginationState.hasMore && ( )}
)} ) : (

{data.institutions?.length || 0} {t('institutions')}

{language === 'nl' ? 'Geen locatiegegevens beschikbaar' : 'No location data available'}

)}
); case 'card': return (
} > {data.institutions?.map((inst, idx) => ( ))} {/* Infinite scroll loading indicator */} {infiniteScrollEnabled && isLoadingMore && (
{t('loadingMoreResults')}
)}
{/* Pagination controls - only show button when infinite scroll is disabled */} {paginationState && (
{t('showingResults')} {paginationState.totalLoaded} {t('ofResults')} {paginationState.estimatedTotal ?? '?'} {!infiniteScrollEnabled && paginationState.hasMore ? ( ) : !paginationState.hasMore ? ( {t('allResultsLoaded')} ) : null}
)}
); case 'table': return (
} > {data.institutions?.map((inst, idx) => ( ))} {/* Infinite scroll loading indicator */} {infiniteScrollEnabled && isLoadingMore && ( )}
{language === 'nl' ? 'Naam' : 'Name'} {language === 'nl' ? 'Type' : 'Type'} {language === 'nl' ? 'Plaats' : 'City'} {language === 'nl' ? 'Provincie' : 'Province'}
{inst.name} {inst.type || '-'} {inst.city || '-'} {inst.province || '-'}
{t('loadingMoreResults')}
{/* Pagination controls - only show button when infinite scroll is disabled */} {paginationState && (
{t('showingResults')} {paginationState.totalLoaded} {t('ofResults')} {paginationState.estimatedTotal ?? '?'} {!infiniteScrollEnabled && paginationState.hasMore ? ( ) : !paginationState.hasMore ? ( {t('allResultsLoaded')} ) : null}
)}
); case 'chart': return (
{chartData.labels.length > 0 ? ( ) : (

{language === 'nl' ? 'Geen grafiekgegevens beschikbaar' : 'No chart data available'}

)}
); case 'timeline': return (
{timelineEvents.length > 0 ? ( ) : (

{language === 'nl' ? 'Geen tijdlijngegevens beschikbaar' : 'No timeline data available'}

)}
); case 'network': // Use social network graph for person queries with retrieved results if (queryType === 'person' && retrievedResults && retrievedResults.length > 0) { return (
); } // Fall back to standard network graph for institution relationships return (
{data.graphData && data.graphData.nodes.length > 0 ? ( ) : (

{language === 'nl' ? 'Geen netwerkgegevens beschikbaar' : 'No network data available'}

)}
); default: return null; } }; return (
{/* Reply type indicator badge */} {replyType && ( )} {(['map', 'card', 'table', 'chart', 'timeline', 'network'] as VisualizationType[]).map(vizType => ( ))}
{/* Infinite scroll toggle - only for card/table views */} {(type === 'card' || type === 'table') && onToggleInfiniteScroll && ( )}
{/* 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 ( <> {/* 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 ? (
setSelectedInstitutionId(inst.id === selectedInstitutionId ? null : inst.id)} showClustering={secondaryMapCoords.length > 50} />
) : null; })()} ); })() : ( renderVisualization() )}
{data.institutions && data.institutions.length > 0 && (
{data.institutions.length} {t('resultsFound')}
)}
); }; // ============================================================================ // 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([]); const [input, setInput] = useState(''); const [showModelDropdown, setShowModelDropdown] = useState(false); const [showHistoryDropdown, setShowHistoryDropdown] = useState(false); const [history, setHistory] = useState>([]); const [notification, setNotification] = useState(null); const [copiedId, setCopiedId] = useState(null); const [vizExpanded, setVizExpanded] = useState(false); const [activeVizType, setActiveVizType] = useState('card'); const [activeVizData, setActiveVizData] = useState(null); const [activeRetrievedResults, setActiveRetrievedResults] = useState(null); const [activeQueryType, setActiveQueryType] = useState(null); // Reply type classification from backend classify_and_format() const [activeReplyType, setActiveReplyType] = useState(null); const [activeReplyContent, setActiveReplyContent] = useState(null); // Secondary reply type for composite visualizations (e.g., map_points when primary is factual_count) const [activeSecondaryReplyType, setActiveSecondaryReplyType] = useState(null); const [activeSecondaryReplyContent, setActiveSecondaryReplyContent] = useState(null); // Embedding Projector state const [showProjector, setShowProjector] = useState(false); const [projectorSimpleMode, setProjectorSimpleMode] = useState(false); const [projectorPinned, setProjectorPinned] = useState(false); const [embeddingPoints, setEmbeddingPoints] = useState([]); const [embeddingsLoading, setEmbeddingsLoading] = useState(false); const [highlightedSourceIndices, setHighlightedSourceIndices] = useState([]); const [additionalContextPoints, setAdditionalContextPoints] = useState([]); // 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([]); // Load All state for embedding projector const [isLoadingAllEmbeddings, setIsLoadingAllEmbeddings] = useState(false); const [totalEmbeddingPoints, setTotalEmbeddingPoints] = useState(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('oxigraph'); const [contextGraphData, setContextGraphData] = useState(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(null); const [cacheSimilarityThreshold, setCacheSimilarityThresholdLocal] = useState(0.92); const [bypassCache, setBypassCache] = useState(false); const [refreshingMessageId, setRefreshingMessageId] = useState(null); // Data source selection state - default to qdrant + sparql (fastest combination) const [selectedSources, setSelectedSources] = useState(['qdrant', 'sparql']); // Infinite scroll toggle (alternative to Load More button for cards/table) const [infiniteScrollEnabled, setInfiniteScrollEnabled] = useState(false); // Refs const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const inputRef = useRef(null); const modelDropdownRef = useRef(null); const historyDropdownRef = useRef(null); const fileInputRef = useRef(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(); // 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; 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 | undefined; const nameAttr = record.n as { value?: string } | undefined; const name = nameAttr?.value || (entity as Record)?.name as string || ''; // Extract entity identifier if available const entityId = entity ? String((entity as Record).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)?.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 = { '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) => { 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 (
{/* Notification Toast */} {notification && (
{notification}
)} {/* Hidden file input */} {/* Main Layout */}
{/* Chat Panel */}
{/* Header */}

{t('pageTitle')}

{t('pageSubtitle')}

{/* Input Area - Top */}