// Message component for individual messages const Message = ({ message, isLoading, isLastAssistant }) => { const isUser = message.role === 'user'; const [urlMap, setUrlMap] = React.useState({}); return (
{/* Render user messages as plain text, assistant as Markdown using marked */} {isUser ? (
{message.content}
) : ( <>
{((isLoading && isLastAssistant) || (message.content && isLoading)) && } )} {/* Sources */} {message.sources && message.sources.length > 0 && (
Sources:
    {message.sources.map((source, i) => { let sourceText = ''; let link = null; if (source.source_type === 'hydrocarbon_article') { sourceText = `${source.title || 'Article'}`; if (source.content_id && urlMap[source.content_id]) { link = urlMap[source.content_id]; } else if (source.url) { link = source.url; } else if (source.source) { link = source.source; } } else if (source.source_type === 'PDF') { sourceText = `${source.source || 'Document'}`; if (source.page > 0) sourceText += ` (page ${source.page})`; } else { sourceText = source.source || 'Unknown source'; } return (
  • {link ? ( {sourceText} ) : ( sourceText )}
  • ); })}
)}
); }; // Typing indicator component const TypingIndicator = () => (
); // Main App component function App() { const [messages, setMessages] = React.useState([]); const [input, setInput] = React.useState(''); const [isLoading, setIsLoading] = React.useState(false); const [threadId, setThreadId] = React.useState(localStorage.getItem('threadId') || ''); const messagesEndRef = React.useRef(null); const inputRef = React.useRef(null); // Focus input on load React.useEffect(() => { if (inputRef.current) { inputRef.current.focus(); } }, []); // Scroll to bottom when messages change React.useEffect(() => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages]); // Add custom CSS React.useEffect(() => { const style = document.createElement('style'); style.textContent = ` @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); body { font-family: 'Inter', system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; } .btn-shine { position: relative; overflow: hidden; } .btn-shine::after { content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: linear-gradient( to bottom right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.1) 100% ); transform: rotate(30deg); transition: transform 0.5s; } .btn-shine:hover::after { transform: rotate(30deg) translate(10%, 10%); } .animate-pulse { animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } `; document.head.appendChild(style); return () => { document.head.removeChild(style); }; }, []); const handleSend = async () => { if (!input.trim() || isLoading) return; const userMessage = input; setInput(''); setIsLoading(true); // Add user message to state setMessages(prevMessages => [...prevMessages, { role: 'user', content: userMessage }]); try { // Call API const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ user_input: userMessage, thread_id: threadId || null }), }); if (!response.ok) { const errorText = await response.text(); console.error(`API error (${response.status}):`, errorText); throw new Error(`API error: ${response.status} - ${errorText}`); } const data = await response.json(); // Log entire response for debugging console.log("API Response:", data); // Validate response structure if (!data.response) { console.error("Invalid API response - missing 'response' field:", data); throw new Error("Invalid API response format"); } // Always use the thread ID from the server if (data.thread_id) { console.log(`Using thread ID: ${data.thread_id}`); setThreadId(data.thread_id); localStorage.setItem('threadId', data.thread_id); } // Add bot response to state, ensuring sources are properly attached setMessages(prevMessages => [ ...prevMessages, { role: 'assistant', content: data.response, sources: Array.isArray(data.sources) ? data.sources : [] // Ensure sources is an array } ]); } catch (error) { console.error('Error during API call:', error); setMessages(prevMessages => [ ...prevMessages, { role: 'system', content: `Error: ${error.message || 'An unknown error occurred'}` } ]); } finally { setIsLoading(false); // Focus input after response if (inputRef.current) { inputRef.current.focus(); } } }; // New streaming version of handleSend that uses the /stream_chat endpoint const handleStreamSend = async () => { if (!input.trim() || isLoading) return; const userMessage = input; setInput(''); setIsLoading(true); // Add user message to state setMessages(prevMessages => [...prevMessages, { role: 'user', content: userMessage }]); // Always add the assistant message bubble immediately setMessages(prevMessages => [ ...prevMessages, { role: 'assistant', content: '', sources: [] } ]); let firstTokenReceived = false; try { // Make the request to the streaming endpoint const response = await fetch('/stream_chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ user_input: userMessage, thread_id: threadId || null }), }); if (!response.ok) { const errorText = await response.text(); console.error(`API error (${response.status}):`, errorText); throw new Error(`API error: ${response.status} - ${errorText}`); } // Get the reader from the response body stream const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; // Process the stream while (true) { const { value, done } = await reader.read(); if (done) break; // Decode the chunk and add it to our buffer buffer += decoder.decode(value, { stream: true }); // Process all complete events in the buffer const events = buffer.split('\n\n'); buffer = events.pop() || ''; // Keep the last incomplete chunk in the buffer for (const eventStr of events) { if (!eventStr.trim()) continue; // Only look for data: lines const dataLine = eventStr.split('\n').find(line => line.startsWith('data:')); if (!dataLine) continue; let data; try { data = JSON.parse(dataLine.slice(5).trim()); } catch (e) { continue; } // Update assistant message as soon as a token arrives if (data.token !== undefined) { setMessages(prevMessages => { const newMessages = [...prevMessages]; const assistantMessageIndex = newMessages.length - 1; if (assistantMessageIndex >= 0 && newMessages[assistantMessageIndex].role === 'assistant') { newMessages[assistantMessageIndex].content += data.token; } return newMessages; }); if (!firstTokenReceived && data.token && data.token !== "") { // setIsLoading(false); firstTokenReceived = true; } } // Attach sources when received if (data.sources) { setMessages(prevMessages => { const newMessages = [...prevMessages]; const assistantMessageIndex = newMessages.length - 1; if (assistantMessageIndex >= 0 && newMessages[assistantMessageIndex].role === 'assistant') { newMessages[assistantMessageIndex].sources = data.sources; } return newMessages; }); } if (data.error) { setIsLoading(false); } } } } catch (error) { console.error('Error during streaming API call:', error); // Update the last message to show the error, or add a new system message setMessages(prevMessages => { const newMessages = [...prevMessages]; const lastMessageIndex = newMessages.length - 1; // If the last message is an empty assistant message, replace it if (lastMessageIndex >= 0 && newMessages[lastMessageIndex].role === 'assistant' && !newMessages[lastMessageIndex].content.trim()) { newMessages.pop(); // Remove the empty assistant message } // Add error message newMessages.push({ role: 'system', content: `Error: ${error.message || 'An unknown error occurred'}` }); return newMessages; }); } finally { setIsLoading(false); // Focus input after response if (inputRef.current) { inputRef.current.focus(); } } }; const handleNewChat = () => { if (confirm('Start a new chat? This will clear the current conversation.')) { setMessages([]); setThreadId(''); localStorage.removeItem('threadId'); } }; // Use the streaming version by default const handleSendMessage = handleStreamSend; return (
{/* Header - fixed height */}

AI Assistant

{/* Messages container - takes remaining space, scrollable */}
{messages.length === 0 ? (

How can I help you today?

Ask me anything about the documents I've processed, and I'll provide insights based on the available information.

) : ( <> {messages.map((msg, index) => ( ))}
)}
{/* Input container - fixed at bottom with set height and solid background */}