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" }}
/>
}
_hover={{ bg: "rebel.500", color: "dark.500" }}
>
Send
>
)}
);
}
export default App;