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
2906 lines
120 KiB
TypeScript
2906 lines
120 KiB
TypeScript
/**
|
||
* 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;
|