Spaces:
Running
Running
| 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 ( | |
| <Box | |
| bg={isUser ? 'rebel.500' : 'imperial.500'} | |
| p={3} | |
| borderRadius="md" | |
| borderWidth="1px" | |
| borderColor={isUser ? 'brand.500' : 'dark.500'} | |
| alignSelf={isUser ? 'flex-end' : 'flex-start'} | |
| maxW="80%" | |
| boxShadow="0 0 5px" | |
| color={isUser ? 'dark.500' : 'light.500'} | |
| ref={messageRef} | |
| position="relative" | |
| > | |
| <Text fontWeight="bold" fontSize="sm" mb={1}> | |
| {isUser ? 'Rebel Commander' : 'Jedi Archives'} | |
| </Text> | |
| {isStreaming && !isUser ? ( | |
| <Box position="relative"> | |
| <ReactMarkdown>{displayedText}</ReactMarkdown> | |
| {charIndex < message.length && ( | |
| <Box | |
| as="span" | |
| display="inline-block" | |
| w="10px" | |
| h="16px" | |
| bg="brand.500" | |
| position="absolute" | |
| ml="2px" | |
| opacity={0.8} | |
| animation="blink 1s step-end infinite" | |
| sx={{ | |
| '@keyframes blink': { | |
| '0%, 100%': { opacity: 0 }, | |
| '50%': { opacity: 1 } | |
| } | |
| }} | |
| /> | |
| )} | |
| </Box> | |
| ) : ( | |
| <ReactMarkdown>{message}</ReactMarkdown> | |
| )} | |
| </Box> | |
| ); | |
| } | |
| 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 ( | |
| <Box | |
| {...getRootProps()} | |
| border="2px dashed" | |
| borderColor="brand.500" | |
| borderRadius="md" | |
| p={10} | |
| textAlign="center" | |
| cursor="pointer" | |
| bg="space.100" | |
| _hover={{ bg: 'space.900', borderColor: 'rebel.500' }} | |
| > | |
| <input {...getInputProps()} /> | |
| <VStack spacing={2}> | |
| <FiUpload size={30} color="#ffe81f" /> | |
| <Text>Drop a holocron (PDF or text file) here, or click to select</Text> | |
| <Text fontSize="sm" color="brand.500"> | |
| Max file size: 5MB - suitable for Death Star plans | |
| </Text> | |
| {isUploading && ( | |
| <> | |
| <Text color="brand.500">Uploading to the Jedi Archives...</Text> | |
| <Progress | |
| value={processingStatus === 'uploading' ? uploadProgress : processingProgress} | |
| size="sm" | |
| colorScheme="yellow" | |
| width="100%" | |
| borderRadius="md" | |
| /> | |
| {processingStatus && ( | |
| <Text | |
| color={processingStatus === 'failed' ? 'imperial.500' : 'brand.500'} | |
| fontSize="sm" | |
| mt={2} | |
| > | |
| {getStatusMessage()} | |
| </Text> | |
| )} | |
| </> | |
| )} | |
| </VStack> | |
| </Box> | |
| ); | |
| } | |
| 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 ( | |
| <ChakraProvider theme={starWarsTheme}> | |
| <Box bg="space.500" minH="100vh" py={8}> | |
| <Container maxW="container.lg"> | |
| <VStack spacing={6} align="stretch" h="100vh"> | |
| <Box textAlign="center" mb={4}> | |
| <Heading | |
| as="h1" | |
| size="xl" | |
| color="brand.500" | |
| textShadow="0 0 10px #ffe81f" | |
| letterSpacing="2px" | |
| > | |
| Jedi Archives Chat | |
| </Heading> | |
| <Text color="light.500" mt={2}>The galaxy's knowledge at your fingertips</Text> | |
| </Box> | |
| {!sessionId ? ( | |
| <FileUploader onFileUpload={handleFileUpload} /> | |
| ) : ( | |
| <> | |
| <Flex justify="space-between" align="center"> | |
| <Text fontWeight="bold" color="brand.500"> | |
| Current holocron: {fileName} {isDocProcessing && "(Jedi Council analyzing...)"} | |
| </Text> | |
| <Button | |
| size="sm" | |
| colorScheme="yellow" | |
| variant="outline" | |
| onClick={() => { | |
| setSessionId(null); | |
| setFileName(null); | |
| setMessages([]); | |
| setIsDocProcessing(false); | |
| }} | |
| > | |
| Access different holocron | |
| </Button> | |
| </Flex> | |
| <Divider borderColor="brand.500" /> | |
| <Box | |
| flex="1" | |
| overflowY="auto" | |
| p={4} | |
| bg="space.100" | |
| borderRadius="md" | |
| borderWidth="1px" | |
| borderColor="brand.500" | |
| boxShadow="0 0 15px #ffe81f22" | |
| minH="300px" | |
| > | |
| <VStack spacing={4} align="stretch"> | |
| {messages.map((msg, idx) => ( | |
| <ChatMessage | |
| key={idx} | |
| message={msg.text} | |
| isUser={msg.isUser} | |
| isStreaming={idx === streamingIndex} | |
| /> | |
| ))} | |
| {isDocProcessing && ( | |
| <Box textAlign="center" p={4}> | |
| <Progress | |
| size="xs" | |
| isIndeterminate | |
| colorScheme="yellow" | |
| width="80%" | |
| mx="auto" | |
| /> | |
| <Text mt={2} color="brand.500"> | |
| The Force is strong with this document... Processing in progress | |
| </Text> | |
| </Box> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </VStack> | |
| </Box> | |
| <HStack> | |
| <Input | |
| placeholder={isDocProcessing | |
| ? "Waiting for the Jedi Council to complete analysis..." | |
| : "What knowledge do you seek from the holocron?"} | |
| value={inputText} | |
| onChange={(e) => 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" }} | |
| /> | |
| <Button | |
| colorScheme="yellow" | |
| isLoading={isProcessing} | |
| onClick={handleSendMessage} | |
| disabled={!inputText.trim() || isProcessing || isDocProcessing} | |
| leftIcon={<FiSend />} | |
| _hover={{ bg: "rebel.500", color: "dark.500" }} | |
| > | |
| Send | |
| </Button> | |
| </HStack> | |
| </> | |
| )} | |
| </VStack> | |
| </Container> | |
| </Box> | |
| </ChakraProvider> | |
| ); | |
| } | |
| export default App; |