// 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 */}
{/* 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 */}
);
}
// Render the App
const rootElement = document.getElementById('root');
const root = ReactDOM.createRoot(rootElement);
root.render();