import React, { useState, useRef, useEffect } from 'react'; import { ChakraProvider, Box, VStack, HStack, Text, Input, Button, Flex, Heading, Container, useToast, Divider, Progress, extendTheme, Image } from '@chakra-ui/react'; import axios from 'axios'; import { useDropzone } from 'react-dropzone'; import { FiSend, FiUpload } from 'react-icons/fi'; import ReactMarkdown from 'react-markdown'; // Star Wars theme const starWarsTheme = extendTheme({ colors: { brand: { 100: '#ffe81f', // Star Wars yellow 200: '#ffe81f', 300: '#ffe81f', 400: '#ffe81f', 500: '#ffe81f', 600: '#d6c119', 700: '#a99a14', 800: '#7c710f', 900: '#4f480a', }, imperial: { 500: '#ff0000', // Empire red }, rebel: { 500: '#4bd5ee', // Rebel blue }, dark: { 500: '#000000', // Dark side }, light: { 500: '#ffffff', // Light side }, space: { 100: '#05050f', 500: '#0a0a1f', 900: '#000005', } }, fonts: { heading: "'Star Jedi', 'Roboto', sans-serif", body: "'Roboto', sans-serif", }, styles: { global: { body: { bg: 'space.500', color: 'light.500', }, }, }, }); // API URL - Using the browser's current hostname for backend access const getAPIURL = () => { // If we're in development mode (running with npm start) if (process.env.NODE_ENV === 'development') { return 'http://localhost:8000'; } // Get current protocol (http: or https:) const protocol = window.location.protocol; const hostname = window.location.hostname; // For Hugging Face deployment - use the same URL without specifying port if (hostname.includes('hf.space')) { return `${protocol}//${hostname}`; } // When running in other production environments, use port 8000 return `${protocol}//${hostname}:8000`; }; const API_URL = process.env.REACT_APP_API_URL || getAPIURL(); // Debug log console.log('Using API URL:', API_URL); console.log('Environment:', process.env.NODE_ENV); console.log('Window location:', window.location.hostname); // Add axios default timeout and error handling axios.defaults.timeout = 120000; // 120 seconds axios.interceptors.response.use( response => response, error => { console.error('Axios error:', error); // Log the specific details if (error.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx console.error('Error response data:', error.response.data); console.error('Error response status:', error.response.status); console.error('Error response headers:', error.response.headers); } else if (error.request) { // The request was made but no response was received console.error('Error request:', error.request); if (error.code === 'ECONNABORTED') { console.error('Request timed out after', axios.defaults.timeout, 'ms'); } } else { // Something happened in setting up the request that triggered an Error console.error('Error message:', error.message); } return Promise.reject(error); } ); function ChatMessage({ message, isUser, isStreaming }) { const [displayedText, setDisplayedText] = useState(''); const [charIndex, setCharIndex] = useState(0); const messageRef = useRef(null); // Star Wars-style typewriter effect for streamed responses useEffect(() => { if (isUser || !isStreaming) { setDisplayedText(message); return; } // Reset if message changes if (charIndex === 0) { setDisplayedText(''); } // Implement the typing effect with randomized speeds for Star Wars terminal feel if (charIndex < message.length) { const delay = Math.random() * 20 + 10; // Random delay between 10-30ms const timer = setTimeout(() => { setDisplayedText(prev => prev + message[charIndex]); setCharIndex(prevIndex => prevIndex + 1); }, delay); return () => clearTimeout(timer); } }, [message, charIndex, isUser, isStreaming]); // Auto-scroll to the bottom of the message as it streams useEffect(() => { if (isStreaming && messageRef.current) { messageRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); } }, [displayedText, isStreaming]); return ( {isUser ? 'Rebel Commander' : 'Jedi Archives'} {isStreaming && !isUser ? ( {displayedText} {charIndex < message.length && ( )} ) : ( {message} )} ); } function FileUploader({ onFileUpload }) { const toast = useToast(); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [processingStatus, setProcessingStatus] = useState(null); const [processingProgress, setProcessingProgress] = useState(0); const [processingSteps, setProcessingSteps] = useState(0); const { getRootProps, getInputProps } = useDropzone({ maxFiles: 1, maxSize: 5 * 1024 * 1024, // 5MB max size accept: { 'text/plain': ['.txt'], 'application/pdf': ['.pdf'] }, onDropRejected: (rejectedFiles) => { toast({ title: 'Transmission rejected', description: rejectedFiles[0]?.errors[0]?.message || 'File rejected by the Empire', status: 'error', duration: 5000, isClosable: true, }); }, onDrop: async (acceptedFiles) => { if (acceptedFiles.length === 0) return; setIsUploading(true); setUploadProgress(0); const file = acceptedFiles[0]; // Check file size if (file.size > 5 * 1024 * 1024) { toast({ title: 'File too large for hyperdrive', description: 'Maximum file size is 5MB - even the Death Star plans were smaller', status: 'error', duration: 5000, isClosable: true, }); setIsUploading(false); return; } const formData = new FormData(); formData.append('file', file); try { // Either use the API_URL or direct backend based on environment const uploadUrl = `${API_URL}/upload/`; console.log('Uploading file to:', uploadUrl); const response = await axios.post(uploadUrl, formData, { headers: { 'Content-Type': 'multipart/form-data', }, timeout: 180000, // 3 minutes timeout for large files maxContentLength: Infinity, maxBodyLength: Infinity, onUploadProgress: (progressEvent) => { const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); setUploadProgress(percentCompleted); }, // Add retry logic for network errors validateStatus: function (status) { return status >= 200 && status < 500; // Handle 4xx errors in our own logic } }); console.log('Upload response:', response.data); setProcessingStatus('starting'); // Start polling for document processing status const sessionId = response.data.session_id; const pollStatus = async () => { try { const statusUrl = `${API_URL}/session/${sessionId}/status`; console.log('Checking status at:', statusUrl); const statusResponse = await axios.get(statusUrl); console.log('Status response:', statusResponse.data); if (statusResponse.data.status === 'ready') { setProcessingStatus('complete'); onFileUpload(sessionId, file.name); return; } else if (statusResponse.data.status === 'failed') { setProcessingStatus('failed'); toast({ title: 'Processing failed', description: 'There was a disturbance in the Force. Please try again with a different file.', status: 'error', duration: 7000, isClosable: true, }); setIsUploading(false); return; } // Still processing, continue polling setProcessingStatus('processing'); setTimeout(pollStatus, 3000); } catch (error) { console.error('Error checking status:', error); // Continue polling if there are non-critical errors if (error.code === 'ECONNABORTED') { // Request timed out toast({ title: 'Status check timed out', description: 'Your document is being processed by the Jedi Council. Please be patient, this may take time.', status: 'warning', duration: 7000, isClosable: true, }); setProcessingStatus('timeout'); // Keep polling, but with a longer delay setTimeout(pollStatus, 10000); } else { // Other errors, but still try to continue polling setTimeout(pollStatus, 5000); } } }; // Start polling setTimeout(pollStatus, 1000); } catch (error) { console.error('Error uploading file:', error); setProcessingStatus(null); let errorMessage = 'Network error - the Death Star has jammed our comms'; if (error.response) { errorMessage = error.response.data?.detail || `Imperial error (${error.response.status})`; } else if (error.code === 'ECONNABORTED') { errorMessage = 'Request timed out. Even the Millennium Falcon would struggle with this file.'; } toast({ title: 'Upload failed', description: errorMessage, status: 'error', duration: 5000, isClosable: true, }); setIsUploading(false); } } }); // Move pollSessionStatus inside the component where it has access to the necessary variables const pollSessionStatus = async (sessionId, file, retries = 40, interval = 5000) => { // Increased retries from 30 to 40 for longer processing documents let currentRetry = 0; while (currentRetry < retries) { try { const statusUrl = `${API_URL}/session/${sessionId}/status`; console.log(`Checking status (attempt ${currentRetry + 1}/${retries}):`, statusUrl); const statusResponse = await axios.get(statusUrl, { timeout: 30000 // 30 second timeout for status checks }); console.log('Status response:', statusResponse.data); if (statusResponse.data.status === 'ready') { setProcessingStatus('complete'); setProcessingProgress(100); onFileUpload(sessionId, file.name); return; } else if (statusResponse.data.status === 'failed') { setProcessingStatus('failed'); throw new Error('Processing failed on server'); } // Still processing, update progress based on attempt number setProcessingStatus('processing'); // Calculate progress - more rapid at start, slower towards end const progressIncrement = 75 / retries; // Max out at 75% during polling setProcessingProgress(Math.min(5 + (currentRetry * progressIncrement), 75)); // Increment processing steps to show activity setProcessingSteps(prev => prev + 1); await new Promise(resolve => setTimeout(resolve, interval)); currentRetry++; // Increase interval slightly for each retry to prevent overwhelming the server interval = Math.min(interval * 1.1, 15000); // Cap at 15 seconds } catch (error) { console.error('Error checking status:', error); // If we hit a timeout or network issue, wait a bit longer before retrying await new Promise(resolve => setTimeout(resolve, interval * 2)); currentRetry++; } } // If we've exhausted all retries and still don't have a ready status throw new Error('Status polling timed out'); }; // Status message based on current processing state const getStatusMessage = () => { const steps = ['Analyzing text', 'Splitting document', 'Creating embeddings', 'Building vector database', 'Finalizing']; const currentStep = steps[processingSteps % steps.length]; switch(processingStatus) { case 'starting': return 'Initiating hyperspace jump...'; case 'uploading': return 'Sending document to the Jedi Archives...'; case 'processing': return `${currentStep}... This may take several minutes.`; case 'timeout': return 'Document processing is taking longer than expected. Patience, young Padawan...'; case 'failed': return 'Document processing failed. The dark side clouded this document.'; case 'complete': return 'Your document has joined the Jedi Archives!'; default: return ''; } }; return ( Drop a holocron (PDF or text file) here, or click to select Max file size: 5MB - suitable for Death Star plans {isUploading && ( <> Uploading to the Jedi Archives... {processingStatus && ( {getStatusMessage()} )} )} ); } function App() { const [sessionId, setSessionId] = useState(null); const [fileName, setFileName] = useState(null); const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const [isDocProcessing, setIsDocProcessing] = useState(false); const [streamingIndex, setStreamingIndex] = useState(-1); // Track which message is streaming const messagesEndRef = useRef(null); const toast = useToast(); const handleFileUpload = (newSessionId, name) => { setSessionId(newSessionId); setFileName(name); setIsDocProcessing(false); setMessages([ { text: `"${name}" has been added to the Jedi Archives. What knowledge do you seek?`, isUser: false } ]); // Don't poll again - already handled in FileUploader }; const handleSendMessage = async () => { if (!inputText.trim() || !sessionId || isDocProcessing) return; const userMessage = inputText; setInputText(''); // Add user message right away setMessages(prev => [...prev, { text: userMessage, isUser: true }]); // Add empty response message that will be filled via streaming const messageIndex = messages.length + 1; // +1 since we just added user message setMessages(prev => [...prev, { text: '', isUser: false }]); setStreamingIndex(messageIndex); setIsProcessing(true); try { const queryUrl = `${API_URL}/query/`; console.log('Sending query to:', queryUrl); // We need to handle the streaming response from the backend const response = await fetch(queryUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ session_id: sessionId, query: userMessage }) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } // Get the response reader for streaming const reader = response.body.getReader(); const decoder = new TextDecoder(); let accumulatedResponse = ''; try { // Process the stream as it comes in while (true) { const { value, done } = await reader.read(); // If the stream is done, break out of the loop if (done) break; // Decode the chunk and add it to our response const chunk = decoder.decode(value, { stream: true }); accumulatedResponse += chunk; // Update the current message with the accumulated response setMessages(prev => prev.map((msg, idx) => idx === messageIndex ? { ...msg, text: accumulatedResponse } : msg )); } } catch (streamError) { console.error('Stream processing error:', streamError); } finally { // We're done streaming, clean up setStreamingIndex(-1); setIsProcessing(false); } } catch (error) { console.error('Error sending message:', error); setStreamingIndex(-1); // Handle specific errors if (error.response?.status === 409) { // Document still processing toast({ title: 'Document still processing', description: 'The Jedi Council is still analyzing this document. Please wait a moment and try again.', status: 'warning', duration: 5000, isClosable: true, }); setMessages(prev => [...prev.slice(0, -1), { text: "The Jedi Council is still analyzing this document. Patience, young Padawan.", isUser: false }]); } else { // General error toast({ title: 'Error', description: error.response?.data?.detail || 'A disturbance in the Force - make sure the backend is operational', status: 'error', duration: 5000, isClosable: true, }); setMessages(prev => [...prev.slice(0, -1), { text: "I find your lack of network connectivity disturbing. Please try again.", isUser: false }]); } setIsProcessing(false); } }; // Scroll to the bottom of messages React.useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Handle Enter key press const handleKeyPress = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }; return ( Jedi Archives Chat The galaxy's knowledge at your fingertips {!sessionId ? ( ) : ( <> Current holocron: {fileName} {isDocProcessing && "(Jedi Council analyzing...)"} {messages.map((msg, idx) => ( ))} {isDocProcessing && ( The Force is strong with this document... Processing in progress )}
setInputText(e.target.value)} onKeyPress={handleKeyPress} disabled={isProcessing || isDocProcessing} bg="space.100" color="light.500" borderColor="brand.500" _hover={{ borderColor: "rebel.500" }} _focus={{ borderColor: "rebel.500", boxShadow: "0 0 0 1px #4bd5ee" }} /> )} ); } export default App;