Spaces:
Runtime error
Runtime error
| import React, { useState, useCallback, useEffect } from 'react'; | |
| import * as XLSX from 'xlsx'; | |
| import { AppState, AnalysisResult, ExtractedDataRow, TableData, AIModel, AVAILABLE_MODELS } from './types'; | |
| import { Icons, APP_NAME, APP_VERSION } from './constants'; | |
| import FileUpload from './components/FileUpload'; | |
| import ProcessingView from './components/ProcessingView'; | |
| import DataGrid from './components/DataGrid'; | |
| import DataVisualizer from './components/DataVisualizer'; | |
| import PdfViewer from './components/PdfViewer'; | |
| import ChatInterface from './components/ChatInterface'; | |
| import ModelSelector from './components/ModelSelector'; | |
| import { analyzeDocument } from './services/geminiService'; | |
| const App: React.FC = () => { | |
| const [state, setState] = useState<AppState>(AppState.IDLE); | |
| const [currentFile, setCurrentFile] = useState<File | null>(null); | |
| const [fileBase64, setFileBase64] = useState<string | null>(null); | |
| const [result, setResult] = useState<AnalysisResult | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [selectedModel, setSelectedModel] = useState<AIModel>(AVAILABLE_MODELS[0]); | |
| // UI State | |
| const [activeTab, setActiveTab] = useState<'tables' | 'visuals' | 'chat'>('tables'); | |
| const [unitSystem, setUnitSystem] = useState<'Original' | 'Metric' | 'Imperial'>('Original'); | |
| const [currentPdfPage, setCurrentPdfPage] = useState<number>(1); | |
| const [usingRLM, setUsingRLM] = useState<boolean>(false); | |
| // RLM threshold - should match backend | |
| const RLM_PAGE_THRESHOLD = 300; | |
| const handleFileSelect = useCallback(async (file: File) => { | |
| setCurrentFile(file); | |
| setState(AppState.UPLOADING); | |
| setError(null); | |
| const reader = new FileReader(); | |
| reader.onload = async () => { | |
| const base64 = (reader.result as string).split(',')[1]; | |
| setFileBase64(base64); | |
| try { | |
| setState(AppState.ANALYZING); | |
| // Check if document is large enough to use RLM | |
| // For PDFs, we can estimate page count from file size (rough: ~50KB per page average) | |
| if (file.type === 'application/pdf') { | |
| // Just estimation log, actual decision is now user-controlled | |
| const estimatedPages = Math.ceil(file.size / (50 * 1024)); | |
| console.log(`[App] PDF detected (~${estimatedPages} pages estimated). RLM Mode: ${usingRLM}`); | |
| } | |
| // Analysis call with manual RLM toggle | |
| const analysisData = await analyzeDocument(base64, file.type, selectedModel, usingRLM); | |
| // Update usingRLM based on actual backend response | |
| if (analysisData.using_rlm) { | |
| setUsingRLM(true); | |
| } | |
| analysisData.metadata = { | |
| ...analysisData.metadata, | |
| fileName: file.name, | |
| fileSize: file.size, | |
| fileType: file.type | |
| }; | |
| setResult(analysisData); | |
| setState(AppState.COMPLETE); | |
| } catch (err: any) { | |
| console.error(err); | |
| setError(err.message || "An unexpected error occurred during processing."); | |
| setState(AppState.ERROR); | |
| } | |
| }; | |
| reader.onerror = () => { | |
| setError("Failed to read file."); | |
| setState(AppState.ERROR); | |
| }; | |
| reader.readAsDataURL(file); | |
| }, [selectedModel]); | |
| // Handle Editing from DataGrid | |
| const handleTableUpdate = (tableIndex: number, newRows: ExtractedDataRow[]) => { | |
| if (!result) return; | |
| const newTables = [...result.tables]; | |
| newTables[tableIndex] = { ...newTables[tableIndex], rows: newRows }; | |
| setResult({ ...result, tables: newTables }); | |
| }; | |
| // Excel Export | |
| const handleExcelExport = () => { | |
| if (!result?.tables) return; | |
| const wb = XLSX.utils.book_new(); | |
| // Create Summary Sheet | |
| const summaryData = [ | |
| ["File Name", result.metadata.fileName], | |
| ["Date Processed", result.metadata.dateProcessed], | |
| ["Executive Summary", result.summary] | |
| ]; | |
| const summaryWs = XLSX.utils.aoa_to_sheet(summaryData); | |
| XLSX.utils.book_append_sheet(wb, summaryWs, "Summary"); | |
| // Create Sheet for each extracted table | |
| result.tables.forEach((table, idx) => { | |
| if (!table.rows || table.rows.length === 0) return; | |
| // Remove internal confidence key for export | |
| const cleanRows = table.rows.map(r => { | |
| const { __confidence, ...rest } = r; | |
| return rest; | |
| }); | |
| const ws = XLSX.utils.json_to_sheet(cleanRows); | |
| // Sheet names limited to 31 chars | |
| const sheetName = table.title.replace(/[\[\]\*\/\\\?]/g, '').substring(0, 31) || `Table ${idx} `; | |
| XLSX.utils.book_append_sheet(wb, ws, sheetName); | |
| }); | |
| XLSX.writeFile(wb, `PetroMind_Export_${Date.now()}.xlsx`); | |
| }; | |
| // Unit Conversion Helper | |
| const getDisplayedTables = (): TableData[] => { | |
| if (!result) return []; | |
| if (unitSystem === 'Original') return result.tables; | |
| // Deep copy to avoid mutating state directly during render | |
| const convertedTables = JSON.parse(JSON.stringify(result.tables)); | |
| return convertedTables.map((table: TableData) => { | |
| const newRows = table.rows.map((row) => { | |
| const newRow: ExtractedDataRow = {}; | |
| Object.keys(row).forEach(key => { | |
| let val = row[key]; | |
| let newKey = key; | |
| // Simple Heuristic Conversion Logic | |
| if (typeof val === 'number' || (typeof val === 'string' && !isNaN(parseFloat(val)))) { | |
| const numVal = parseFloat(val as string); | |
| if (unitSystem === 'Metric') { | |
| if (key.match(/_ft$|_feet$/i)) { | |
| newKey = key.replace(/_ft$|_feet$/i, '_m'); | |
| newRow[newKey] = (numVal * 0.3048).toFixed(2); | |
| return; | |
| } | |
| if (key.match(/_psi$/i)) { | |
| newKey = key.replace(/_psi$/i, '_bar'); | |
| newRow[newKey] = (numVal * 0.0689476).toFixed(2); | |
| return; | |
| } | |
| } else if (unitSystem === 'Imperial') { | |
| if (key.match(/_m$|_meter$/i)) { | |
| newKey = key.replace(/_m$|_meter$/i, '_ft'); | |
| newRow[newKey] = (numVal * 3.28084).toFixed(2); | |
| return; | |
| } | |
| if (key.match(/_bar$/i)) { | |
| newKey = key.replace(/_bar$/i, '_psi'); | |
| newRow[newKey] = (numVal * 14.5038).toFixed(2); | |
| return; | |
| } | |
| } | |
| } | |
| newRow[newKey] = val; | |
| }); | |
| return newRow; | |
| }); | |
| return { ...table, rows: newRows }; | |
| }); | |
| }; | |
| const displayedTables = getDisplayedTables(); | |
| const reset = () => { | |
| setState(AppState.IDLE); | |
| setCurrentFile(null); | |
| setFileBase64(null); | |
| setResult(null); | |
| setError(null); | |
| }; | |
| return ( | |
| <div className="h-screen bg-industrial-950 text-gray-100 font-sans selection:bg-petro-500 selection:text-white flex flex-col overflow-hidden"> | |
| {/* Header */} | |
| <header className="border-b border-industrial-900 bg-industrial-950/80 backdrop-blur-md shrink-0"> | |
| <div className="w-full px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> | |
| <div className="flex items-center space-x-3"> | |
| <div className="p-2 bg-gradient-to-br from-petro-600 to-petro-800 rounded-lg shadow-lg shadow-petro-900/50"> | |
| <Icons.Activity /> | |
| </div> | |
| <div> | |
| <h1 className="text-xl font-display font-bold tracking-tight text-white">{APP_NAME}</h1> | |
| <p className="text-xs text-petro-400 font-mono">ENTERPRISE EDITION</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center space-x-4"> | |
| {state === AppState.COMPLETE && ( | |
| <div className="flex bg-industrial-900 rounded-lg p-1 border border-industrial-800"> | |
| <button onClick={() => setUnitSystem('Original')} className={`px-3 py-1 text-xs font-medium rounded ${unitSystem === 'Original' ? 'bg-industrial-700 text-white' : 'text-gray-400'}`}>Orig</button> | |
| <button onClick={() => setUnitSystem('Metric')} className={`px-3 py-1 text-xs font-medium rounded ${unitSystem === 'Metric' ? 'bg-petro-700 text-white' : 'text-gray-400'}`}>Met</button> | |
| <button onClick={() => setUnitSystem('Imperial')} className={`px-3 py-1 text-xs font-medium rounded ${unitSystem === 'Imperial' ? 'bg-petro-700 text-white' : 'text-gray-400'}`}>Imp</button> | |
| </div> | |
| )} | |
| <span className="hidden md:inline text-xs text-gray-600 font-mono">{APP_VERSION}</span> | |
| </div> | |
| </div> | |
| </header> | |
| {/* Main Content Area */} | |
| <main className="flex-1 overflow-hidden relative"> | |
| {state === AppState.IDLE && ( | |
| <div className="h-full overflow-y-auto p-8 flex items-center justify-center"> | |
| <div className="max-w-2xl w-full animate-fade-in"> | |
| <div className="text-center mb-10"> | |
| <h2 className="text-4xl md:text-5xl font-display font-bold text-white mb-4"> | |
| Intelligent <span className="text-petro-500">O&G Data</span> <br />Extraction | |
| </h2> | |
| <p className="text-lg text-gray-400 max-w-xl mx-auto"> | |
| Upload logs, reports, or surveys. Validate with confidence scores. Visualize trends. Export to Excel. | |
| </p> | |
| </div> | |
| <div className="mb-6 flex flex-col md:flex-row gap-4 items-center justify-between bg-industrial-900/40 p-4 rounded-xl border border-industrial-800/50"> | |
| <div className="flex-1 w-full"> | |
| <ModelSelector | |
| selectedModel={selectedModel} | |
| onModelChange={setSelectedModel} | |
| disabled={false} | |
| /> | |
| </div> | |
| <div className="flex items-center gap-3 px-4 py-2 bg-industrial-950/50 rounded-lg border border-industrial-800/50"> | |
| <div className="flex flex-col"> | |
| <span className="text-sm font-medium text-gray-200">Deep Research (RLM)</span> | |
| <span className="text-[10px] text-gray-500">For large/complex docs</span> | |
| </div> | |
| <button | |
| onClick={() => setUsingRLM(!usingRLM)} | |
| className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-petro-500 focus:ring-offset-2 focus:ring-offset-industrial-950 ${usingRLM ? 'bg-petro-600' : 'bg-industrial-700'}`} | |
| > | |
| <span | |
| className={`${usingRLM ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} | |
| /> | |
| </button> | |
| </div> | |
| </div> | |
| <FileUpload onFileSelect={handleFileSelect} disabled={false} /> | |
| </div> | |
| </div> | |
| )} | |
| {(state === AppState.UPLOADING || state === AppState.ANALYZING) && ( | |
| <div className="h-full flex items-center justify-center p-8"> | |
| <ProcessingView fileName={currentFile?.name || 'Document'} usingRLM={usingRLM} /> | |
| </div> | |
| )} | |
| {state === AppState.ERROR && ( | |
| <div className="h-full flex items-center justify-center p-8"> | |
| <div className="max-w-xl text-center"> | |
| <div className="inline-block p-4 bg-red-900/20 text-red-500 rounded-full mb-4 border border-red-900/50"> | |
| <Icons.AlertTriangle /> | |
| </div> | |
| <h3 className="text-2xl font-bold text-white mb-2">Extraction Failed</h3> | |
| <p className="text-gray-400 mb-8">{error}</p> | |
| <button onClick={reset} className="px-6 py-3 bg-industrial-800 hover:bg-industrial-700 text-white rounded-lg transition-colors border border-industrial-700"> | |
| Try Again | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {state === AppState.COMPLETE && result && ( | |
| <div className="h-full grid grid-cols-1 lg:grid-cols-2 gap-0 divide-x divide-industrial-900"> | |
| {/* Left Panel: Document Viewer (Source of Truth) */} | |
| <div className="hidden lg:block h-full bg-industrial-950 p-4 overflow-hidden"> | |
| {fileBase64 && currentFile && ( | |
| <PdfViewer fileBase64={fileBase64} mimeType={currentFile.type} currentPage={currentPdfPage} /> | |
| )} | |
| </div> | |
| {/* Right Panel: Data & Tools */} | |
| <div className="h-full flex flex-col bg-industrial-950 overflow-hidden"> | |
| {/* Toolbar */} | |
| <div className="p-4 border-b border-industrial-900 flex items-center justify-between bg-industrial-950/50"> | |
| <div className="flex space-x-2"> | |
| <button | |
| onClick={() => setActiveTab('tables')} | |
| className={`flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${activeTab === 'tables' ? 'bg-industrial-800 text-white border border-industrial-700' : 'text-gray-400 hover:text-white'}`} | |
| > | |
| <Icons.Table /> Data Tables | |
| </button> | |
| <button | |
| onClick={() => setActiveTab('visuals')} | |
| className={`flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${activeTab === 'visuals' ? 'bg-industrial-800 text-white border border-industrial-700' : 'text-gray-400 hover:text-white'}`} | |
| > | |
| <Icons.Chart /> Visualization | |
| </button> | |
| <button | |
| onClick={() => setActiveTab('chat')} | |
| className={`flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${activeTab === 'chat' ? 'bg-industrial-800 text-white border border-industrial-700' : 'text-gray-400 hover:text-white'}`} | |
| > | |
| <Icons.MessageSquare /> Chat | |
| </button> | |
| </div> | |
| <div className="flex space-x-2"> | |
| <button onClick={reset} className="flex items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:text-white bg-industrial-800 hover:bg-industrial-700 rounded-lg transition-colors border border-industrial-700"> | |
| <Icons.Refresh /> Home | |
| </button> | |
| <button onClick={handleExcelExport} className="flex items-center gap-2 px-4 py-2 text-sm bg-petro-700 hover:bg-petro-600 text-white rounded-lg shadow-lg shadow-petro-900/20 transition-all"> | |
| <Icons.FileSpreadsheet /> Export Excel | |
| </button> | |
| </div> | |
| </div> | |
| {/* Content Scroll Area */} | |
| <div className="flex-1 overflow-y-auto p-4 custom-scrollbar"> | |
| {/* Summary */} | |
| <div className="mb-6 p-4 bg-industrial-900/50 rounded-lg border border-industrial-800"> | |
| <h4 className="text-xs uppercase text-gray-500 font-bold mb-2">Analysis Summary</h4> | |
| <p className="text-sm text-gray-300">{result.summary}</p> | |
| </div> | |
| {activeTab === 'tables' && ( | |
| <div className="space-y-8 pb-20"> | |
| {displayedTables.map((table, idx) => ( | |
| <div key={idx} className="animate-fade-in-up"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h3 | |
| className="text-md font-display font-bold text-petro-100 uppercase tracking-wide flex items-center gap-2 cursor-pointer hover:text-petro-300 transition-colors" | |
| onClick={() => { | |
| if (table.page_number) { | |
| setCurrentPdfPage(table.page_number); | |
| } else { | |
| setCurrentPdfPage(1); | |
| } | |
| }} | |
| title="Click to jump to page in document" | |
| > | |
| <span className="w-1.5 h-1.5 rounded-full bg-petro-500"></span> | |
| {table.title} | |
| </h3> | |
| </div> | |
| <DataGrid | |
| data={table.rows} | |
| onDataChange={(newRows) => handleTableUpdate(idx, newRows)} | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {activeTab === 'visuals' && ( | |
| <div className="animate-fade-in pb-20"> | |
| <DataVisualizer tables={displayedTables} /> | |
| </div> | |
| )} | |
| {activeTab === 'chat' && ( | |
| <div className="h-full animate-fade-in"> | |
| <ChatInterface onNavigateToPage={(page) => setCurrentPdfPage(page)} /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </main> | |
| </div> | |
| ); | |
| }; | |
| export default App; |