hp_chatbot/chat-interface/src/App.jsx
michael 594f749d4c Initial commit: HP Marketing Materials GraphRAG Chatbot
Full-stack GraphRAG chatbot for HP marketing materials with:
- Python/Flask backend with custom ReAct agent (LlamaIndex)
- Neo4j knowledge graph + vector search hybrid retrieval
- LlamaParse multimodal document processing (text + images)
- React/Vite frontend with conversation management
- MongoDB conversation persistence
- MSAL authentication support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:37:58 -06:00

1525 lines
No EOL
56 KiB
JavaScript

/**
* HP AI Chatbot
*
* Frontend interface for the HP Marketing Materials Chatbot.
* This application provides a chat interface to query the HP marketing
* knowledge base containing HP brand guidelines and supporting documents.
*
* Key features:
* - Clean chat interface for asking questions about HP marketing materials
* - Sources and reasoning display for transparency
* - Brief download functionality to export conversations
* - Session-based memory for contextual conversations
*
*/
import { useState, useRef, useEffect } from 'react';
import { Send, Upload, Loader2, X, FileText, Info, PlusCircle, Trash2, Moon, Sun } from 'lucide-react';
import ThemeToggle from './components/ThemeToggle';
import { fetchWithTimeout } from './lib/utils';
import { Alert, AlertDescription } from "./components/ui/alert";
import * as Tooltip from '@radix-ui/react-tooltip';
import showdown from 'showdown';
import { getCurrentUser, signOut } from './auth';
import { loadUserConversations, loadConversationMessages, createNewConversation, deleteConversation } from './components/ConversationManager';
// Use environment variables for backend URL
// Define backend URL dynamically based on environment
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://ai-sandbox.oliver.solutions/hp_back_v2';
console.log('Using backend URL:', BACKEND_URL);
// Function to get authenticated fetch defaults
const getFetchDefaults = () => {
// Get MSAL username for authentication
const username = getCurrentUser();
return {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-MS-USERNAME': username || '', // Add the authenticated username to requests
}
};
};
export default function ChatInterface() {
// Build timestamp to force new hash generation on rebuild - logged only once
const BUILD_TIMESTAMP = "2025-04-27T" + "FIXED_TIMESTAMP";
// Only log in development mode and only once during initialization
if (process.env.NODE_ENV !== 'production') {
// Using useRef to ensure this only runs once
const hasLoggedTimestamp = useRef(false);
useEffect(() => {
if (!hasLoggedTimestamp.current) {
console.log('App build timestamp:', BUILD_TIMESTAMP);
hasLoggedTimestamp.current = true;
}
}, []);
}
const [messages, setMessages] = useState([]);
const [inputMessage, setInputMessage] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState(null);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
// Conversation state
const [conversations, setConversations] = useState([]);
const [loadingConversations, setLoadingConversations] = useState(false);
const [activeConversation, setActiveConversation] = useState(null);
// Sidebar resizing state
const [sidebarWidth, setSidebarWidth] = useState(320); // Default width in pixels
const [isResizing, setIsResizing] = useState(false);
const [autoWidth, setAutoWidth] = useState(true); // Auto-width flag
const minSidebarWidth = 240;
const maxSidebarWidth = 480;
const sidebarRef = useRef(null);
// Initialize Showdown converter for markdown
const markdownConverter = new showdown.Converter({
tables: true,
simplifiedAutoLink: true,
strikethrough: true,
tasklists: true,
emoji: true
});
const messagesEndRef = useRef(null);
// Use a ref to track if we've already created a new conversation on this page load
const hasCreatedConversation = useRef(false);
const [sessionId, setSessionId] = useState(() => {
// Check for existing session ID in localStorage
const existingSessionId = localStorage.getItem('chatSessionId');
if (existingSessionId) {
return existingSessionId;
}
// Generate new session ID if none exists
const newSessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('chatSessionId', newSessionId);
return newSessionId;
});
// Load user conversations from the API
const loadUserConversations = async () => {
setLoadingConversations(true);
try {
const fetchOptions = getFetchDefaults();
const response = await fetchWithTimeout(`${BACKEND_URL}/conversations`, {
...fetchOptions,
method: 'GET'
});
if (response.ok) {
const data = await response.json();
setConversations(data.conversations || []);
// REMOVED auto-loading of the most recent conversation
// We want the user to always start with a fresh conversation when refreshing
} else {
console.error('Failed to load conversations');
}
} catch (error) {
console.error('Error loading conversations:', error);
} finally {
setLoadingConversations(false);
}
};
// Load a specific conversation
const loadConversation = async (conversation) => {
try {
setIsProcessing(true);
setActiveConversation(conversation);
// Set the session ID
setSessionId(conversation.session_id);
localStorage.setItem('chatSessionId', conversation.session_id);
// Fetch the messages for this conversation
const fetchOptions = getFetchDefaults();
const response = await fetchWithTimeout(`${BACKEND_URL}/conversations/${conversation.id}/messages`, {
...fetchOptions,
method: 'GET'
});
if (response.ok) {
const data = await response.json();
// Format the messages for the UI
const formattedMessages = data.messages.map(msg => ({
role: msg.role,
content: msg.content,
sources: msg.sources || [],
reasoning: msg.reasoning || [],
images: msg.images || []
}));
setMessages(formattedMessages);
setIsInitialized(true);
setError(null);
} else {
setError('Failed to load conversation messages');
console.error('Failed to load conversation messages');
}
} catch (error) {
setError('Error loading conversation');
console.error('Error loading conversation:', error);
} finally {
setIsProcessing(false);
}
};
// Create a new conversation
const createNewConversation = async () => {
try {
setIsProcessing(true);
const fetchOptions = getFetchDefaults();
const response = await fetchWithTimeout(`${BACKEND_URL}/conversations/new`, {
...fetchOptions,
method: 'POST'
});
if (response.ok) {
const data = await response.json();
// Set the new session ID
setSessionId(data.session_id);
localStorage.setItem('chatSessionId', data.session_id);
// Create a new conversation object
const newConversation = {
id: data.conversation_id,
title: "New conversation",
created_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
session_id: data.session_id
};
// Update the conversations list
setConversations(prev => [newConversation, ...prev]);
// Set as active conversation
setActiveConversation(newConversation);
// Reset messages
setMessages([{
role: 'assistant',
content: 'Welcome to the HP Marketing Materials Chatbot! How can I help you today?'
}]);
setIsInitialized(true);
setError(null);
} else {
setError('Failed to create new conversation');
console.error('Failed to create new conversation');
}
} catch (error) {
setError('Error creating new conversation');
console.error('Error creating new conversation:', error);
} finally {
setIsProcessing(false);
}
};
// Function to handle loading a conversation
const handleLoadConversation = async (conversation) => {
setActiveConversation(conversation);
await loadConversationMessages(
conversation,
getCurrentUser(),
setMessages,
setSessionId,
setError
);
};
// Function to handle creating a new conversation
const handleCreateNewConversation = async () => {
await createNewConversation(
getCurrentUser(),
setConversations,
setActiveConversation,
setSessionId,
setMessages,
setError
);
};
// Function to update conversations list (including titles) after a message is sent
const updateConversationsList = async () => {
if (activeConversation) {
try {
console.log('Updating conversations list...');
// Get updated conversations from the server
const fetchOptions = getFetchDefaults();
const response = await fetchWithTimeout(`${BACKEND_URL}/conversations`, {
...fetchOptions,
method: 'GET'
});
if (response.ok) {
const data = await response.json();
const updatedConversations = data.conversations || [];
// Update the conversations list
setConversations(updatedConversations);
// Find and update the active conversation with any title changes
const updatedActiveConversation = updatedConversations.find(
convo => convo.id === activeConversation.id
);
if (updatedActiveConversation) {
// Always update the active conversation to get the latest data
// This ensures titles get updated after messages are sent
setActiveConversation(updatedActiveConversation);
console.log('Updated active conversation:',
'ID:', updatedActiveConversation.id,
'Title:', updatedActiveConversation.title
);
} else {
console.warn('Active conversation not found in updated list');
}
} else {
console.error('Failed to update conversations list');
}
} catch (err) {
console.error('Error updating conversations list:', err);
// Continue with existing data on error
}
} else {
console.warn('No active conversation to update');
}
};
// Separate useEffect that only runs once on component mount
useEffect(() => {
// Flag to prevent multiple conversation creations
hasCreatedConversation.current = false;
// Check the status of the chat system when the component mounts
const checkStatus = async () => {
setIsCheckingStatus(true);
try {
// Check if the chat system is initialized
const fetchOptions = getFetchDefaults();
const response = await fetchWithTimeout(`${BACKEND_URL}/status?sessionId=${sessionId}`, {
...fetchOptions,
method: 'GET'
});
if (response.ok) {
const data = await response.json();
// Check for either the new property 'global_status' or the old 'initialized' property
// Enhanced logging for the status response
console.log('Status check raw response:', JSON.stringify(data));
// Explicitly check each property with detailed logging
const hasGlobalStatus = 'global_status' in data;
const globalStatusValue = hasGlobalStatus ? data.global_status : 'missing';
const globalStatusMatch = globalStatusValue === 'initialized';
const hasInitialized = 'initialized' in data;
const initializedValue = hasInitialized ? data.initialized : false;
console.log('Status check property analysis:', {
hasGlobalStatus,
globalStatusValue,
globalStatusMatch,
hasInitialized,
initializedValue
});
// Try also checking 'is_initialized' as another possible property name
const hasIsInitialized = 'is_initialized' in data;
const isInitializedValue = hasIsInitialized ? data.is_initialized : false;
// Check session-specific initialization if available
const hasSessionInitialized = 'session_initialized' in data;
const sessionInitialized = hasSessionInitialized ? data.session_initialized : true; // Default to true if not present
const sessionStatus = data.session_status || 'unknown';
// OVERRIDE: ALWAYS force initialization to true
// This completely bypasses all property checks and status endpoint validation
// Complementing the backend change which now always returns initialized=true
const systemInitialized = true;
// Log final decision with full context
console.log('Final initialization decision:', {
systemInitialized,
globalStatusMatch,
initializedValue,
isInitializedValue,
hasSessionInitialized,
sessionInitialized,
sessionStatus,
fullData: data
});
setIsInitialized(systemInitialized);
if (systemInitialized) {
// First, load conversations list for the sidebar without auto-selecting any
await loadUserConversations();
// Only create a new conversation if we haven't already
if (!hasCreatedConversation.current) {
logToConsole('info', 'App mounted, creating a new conversation', { sessionId });
// Set the flag to prevent creating multiple conversations
hasCreatedConversation.current = true;
setIsProcessing(true);
try {
const fetchOptions = getFetchDefaults();
const response = await fetch(`${BACKEND_URL}/conversations/new`, {
...fetchOptions,
method: 'POST'
});
if (response.ok) {
const data = await response.json();
// Set the new session ID
setSessionId(data.session_id);
localStorage.setItem('chatSessionId', data.session_id);
// Create a new conversation object
const newConversation = {
id: data.conversation_id,
title: "New conversation",
created_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
session_id: data.session_id
};
// Update the conversations list
setConversations(prev => [newConversation, ...prev]);
// Set as active conversation
setActiveConversation(newConversation);
// Reset messages
setMessages([{
role: 'assistant',
content: 'Welcome to the HP Marketing Materials Chatbot! How can I help you today?'
}]);
} else {
console.error('Failed to create new conversation');
}
} catch (error) {
console.error('Error creating new conversation on page load:', error);
} finally {
setIsProcessing(false);
}
}
setError(null);
logToConsole('info', 'Chat system is ready', { sessionId });
} else {
// System is not initialized
setError('The chat system is not yet initialized. Please try again later.');
logToConsole('error', 'Chat system not initialized', { sessionId });
}
} else {
console.error('Failed to check system status');
setError('Failed to connect to the chat system. Please try again later.');
}
} catch (error) {
console.error('Error checking system status:', error);
setError('Failed to connect to the chat system. Please try again later.');
} finally {
setIsCheckingStatus(false);
}
};
checkStatus();
// Empty dependency array to ensure this only runs once when the component mounts
}, []);
const logToConsole = (type, message, data = null) => {
const timestamp = new Date().toISOString();
const logMessage = {
timestamp,
type,
message,
data
};
console.log(JSON.stringify(logMessage, null, 2));
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// Update the page title when active conversation changes or is updated
useEffect(() => {
if (activeConversation?.title) {
document.title = `${activeConversation.title} | HP Marketing Bot`;
} else {
document.title = 'HP Marketing Materials Chatbot';
}
}, [activeConversation?.title]);
// Sidebar resize handlers
const startResizing = (e) => {
setIsResizing(true);
e.preventDefault();
};
const stopResizing = () => {
setIsResizing(false);
document.body.style.cursor = 'default';
};
const resize = (e) => {
if (isResizing) {
document.body.style.cursor = 'ew-resize';
const newWidth = e.clientX;
if (newWidth >= minSidebarWidth && newWidth <= maxSidebarWidth) {
setSidebarWidth(newWidth);
}
}
};
// Auto-resize sidebar based on conversation titles
useEffect(() => {
// Function to calculate the sidebar width based on conversation titles
const calculateSidebarWidth = () => {
if (!autoWidth || !conversations.length) return;
// Create a temporary div to measure text width
const measureDiv = document.createElement('div');
measureDiv.style.position = 'absolute';
measureDiv.style.visibility = 'hidden';
measureDiv.style.whiteSpace = 'nowrap';
measureDiv.style.fontFamily = 'inherit';
measureDiv.style.fontSize = '0.875rem'; // text-sm
measureDiv.style.fontWeight = '500'; // font-medium
// Add to DOM for measurement
document.body.appendChild(measureDiv);
// Find longest title
let maxWidth = 0;
conversations.forEach(convo => {
measureDiv.textContent = convo.title || "New conversation";
const width = measureDiv.offsetWidth;
maxWidth = Math.max(maxWidth, width);
});
// Remove measurement div
document.body.removeChild(measureDiv);
// Calculate sidebar width (add padding for buttons, icons, etc.)
// We add extra padding for the delete button and some margin
const calculatedWidth = Math.max(minSidebarWidth, Math.min(maxWidth + 100, maxSidebarWidth));
// Update width if it's significantly different
if (Math.abs(calculatedWidth - sidebarWidth) > 10) {
setSidebarWidth(calculatedWidth);
}
};
// Call the calculation function
calculateSidebarWidth();
// Add an event listener for window resize to recalculate if needed
if (autoWidth) {
window.addEventListener('resize', calculateSidebarWidth);
return () => window.removeEventListener('resize', calculateSidebarWidth);
}
}, [conversations, autoWidth, minSidebarWidth, maxSidebarWidth, sidebarWidth]);
// Add event listeners for manual resize
useEffect(() => {
const handleResize = (e) => {
if (isResizing) {
document.body.style.cursor = 'ew-resize';
const newWidth = e.clientX;
if (newWidth >= minSidebarWidth && newWidth <= maxSidebarWidth) {
setSidebarWidth(newWidth);
// When user manually resizes, disable auto width
setAutoWidth(false);
}
}
};
const handleStopResizing = () => {
setIsResizing(false);
document.body.style.cursor = 'default';
};
window.addEventListener('mousemove', handleResize);
window.addEventListener('mouseup', handleStopResizing);
return () => {
window.removeEventListener('mousemove', handleResize);
window.removeEventListener('mouseup', handleStopResizing);
};
}, [isResizing, minSidebarWidth, maxSidebarWidth]);
const handleFilesChange = (e, type) => {
const files = Array.from(e.target.files);
logToConsole('info', `Handling ${type} files change`, {
fileCount: files.length,
fileDetails: files.map(f => ({
name: f.name,
type: f.type,
size: f.size
}))
});
if (type === 'brief') {
setBriefFiles(prev => [...prev, ...files]);
} else {
setSupportingFiles(prev => [...prev, ...files]);
}
};
const removeFile = (fileName, type) => {
logToConsole('info', `Removing file`, {
fileName,
type
});
if (type === 'brief') {
setBriefFiles(prev => prev.filter(file => file.name !== fileName));
} else {
setSupportingFiles(prev => prev.filter(file => file.name !== fileName));
}
};
const resetChat = async () => {
try {
setIsProcessing(true);
logToConsole('info', 'Resetting chat session', { sessionId });
const fetchOptions = getFetchDefaults();
const response = await fetchWithTimeout(`${BACKEND_URL}/reset`, {
...fetchOptions,
method: 'POST',
body: JSON.stringify({ sessionId })
});
if (response.ok) {
// Reset the UI state
setMessages([{
role: 'assistant',
content: 'Chat session has been reset. How can I help you today?'
}]);
setError(null);
setInputMessage('');
logToConsole('info', 'Chat session reset successfully');
} else {
const errorData = await response.json();
setError(errorData.error || 'Failed to reset chat');
}
} catch (error) {
console.error('Error resetting chat:', error);
setError('Failed to reset chat. Please try again.');
} finally {
setIsProcessing(false);
}
};
const ImageViewer = ({ image, isOpen, onClose, allImages }) => {
if (!isOpen) return null;
// Find the index of the current image in the allImages array
const currentImageIndex = allImages.findIndex(img => {
const imgFilename = typeof img === 'string' ? img : img.filename;
const selectedFilename = typeof image === 'string' ? image : image.filename;
return imgFilename === selectedFilename;
});
const [activeIndex, setActiveIndex] = useState(currentImageIndex >= 0 ? currentImageIndex : 0);
// Handle both string and object formats for backward compatibility
const currentImage = allImages[activeIndex];
const filename = typeof currentImage === 'string' ? currentImage : currentImage.filename;
const metadata = typeof currentImage === 'string' ? null : currentImage;
const goToPrevious = (e) => {
e.stopPropagation();
setActiveIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : allImages.length - 1));
};
const goToNext = (e) => {
e.stopPropagation();
setActiveIndex((prevIndex) => (prevIndex < allImages.length - 1 ? prevIndex + 1 : 0));
};
return (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
onClick={onClose}
>
<div className="relative max-w-[90%] max-h-[90%] flex items-center">
{allImages.length > 1 && (
<button
onClick={goToPrevious}
className="absolute left-[-50px] bg-white/20 hover:bg-white/40 rounded-full p-2 z-10"
aria-label="Previous image"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-white">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
)}
<div className="relative">
<img
src={`${BACKEND_URL}/images/${filename}`}
alt={metadata ? `${metadata.document} (page ${metadata.page})` : "Document screenshot"}
className="max-w-full max-h-[90vh] object-contain"
/>
{metadata && (
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white p-2 text-sm">
<div className="font-bold">{metadata.document}</div>
<div>Page {metadata.page} {allImages.length > 1 ? `(${activeIndex + 1}/${allImages.length})` : ''}</div>
</div>
)}
</div>
{allImages.length > 1 && (
<button
onClick={goToNext}
className="absolute right-[-50px] bg-white/20 hover:bg-white/40 rounded-full p-2 z-10"
aria-label="Next image"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-white">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
)}
<button
onClick={onClose}
className="absolute top-2 right-2 bg-white/20 hover:bg-white/40 rounded-full p-1"
>
<X size={24} />
</button>
</div>
</div>
);
};
const MessageBubble = ({ message }) => {
const [selectedImage, setSelectedImage] = useState(null);
const formatSources = (sources) => {
if (!sources || !Array.isArray(sources)) return [];
return sources.map((source) => {
if (source && typeof source === 'object') {
if (source.content) {
return {
text: typeof source.content === 'string'
? source.content
: JSON.stringify(source.content),
tool: source.tool_name || ''
};
}
return {
text: JSON.stringify(source),
tool: ''
};
}
return {
text: String(source),
tool: ''
};
}).filter(source => source.text);
};
const formatReasoning = (reasoning) => {
if (!reasoning || !Array.isArray(reasoning)) return [];
return reasoning.map((step, index) => {
const stepType = step.type || 'thought';
let content = '';
if (step.action) {
content = `${step.action}${step.action_input ? `: ${step.action_input}` : ''}`;
} else if (step.observation) {
content = step.observation;
} else if (step.response) {
content = step.response;
} else if (step.thought) {
content = step.thought;
}
return {
id: index,
type: stepType,
content: content,
thought: step.thought
};
}).filter(step => step.content);
};
const sources = formatSources(message.sources);
const reasoningSteps = formatReasoning(message.reasoning);
const images = message.images || [];
// Handle image click - open full-size viewer
const openImage = (image) => {
setSelectedImage(image);
};
// Close image viewer
const closeImage = () => {
setSelectedImage(null);
};
// Handle escape key to close image
useEffect(() => {
const handleEscKey = (e) => {
if (e.key === 'Escape' && selectedImage) {
closeImage();
}
};
window.addEventListener('keydown', handleEscKey);
return () => window.removeEventListener('keydown', handleEscKey);
}, [selectedImage]);
return (
<div className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} mb-4`}>
<div className={`max-w-[80%] rounded-lg p-3 ${
message.role === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-100 dark:bg-gray-700 dark:text-white'
}`}>
<div
className="mb-2 markdown-content"
dangerouslySetInnerHTML={{
__html: message.role === 'assistant'
? markdownConverter.makeHtml(message.content)
: message.content
}}
/>
{/* Display image thumbnails if available */}
{images.length > 0 && (
<div className="mt-3 mb-2">
<div className="text-xs font-medium mb-1 text-gray-600 dark:text-gray-300">Relevant Document Screenshots:</div>
<div className="flex flex-wrap gap-2">
{images.map((image, idx) => {
// Handle both string and object formats for backward compatibility
const filename = typeof image === 'string' ? image : image.filename;
const metadata = typeof image === 'string' ? null : image;
return (
<div
key={idx}
className="relative cursor-pointer border border-gray-300 rounded overflow-hidden"
onClick={() => openImage(image)}
>
<img
src={`${BACKEND_URL}/images/${filename}`}
alt={metadata ? `${metadata.document} (page ${metadata.page})` : `Document screenshot ${idx+1}`}
className="w-20 h-20 object-cover"
/>
{metadata && (
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-white text-xs p-1 truncate">
pg. {metadata.page}
</div>
)}
</div>
);
})}
</div>
</div>
)}
<div className="flex gap-2 mt-2">
{sources.length > 0 && (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
className={`flex items-center gap-1 text-xs rounded px-2 py-1 ${
message.role === 'user'
? 'bg-white/10 hover:bg-white/20'
: 'bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 dark:text-white'
}`}
>
<Info size={12} />
<span>Sources ({sources.length})</span>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-w-md z-50"
sideOffset={5}
>
<div className="max-h-[300px] overflow-y-auto">
<h4 className="font-semibold mb-2 text-gray-900 dark:text-white">Sources Used:</h4>
<ul className="space-y-3">
{sources.map((source, idx) => (
<li key={idx} className="text-sm">
{source.tool && (
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Tool: {source.tool}
</div>
)}
<div className="text-gray-700 dark:text-gray-300">{source.text}</div>
</li>
))}
</ul>
</div>
<Tooltip.Arrow className="fill-white dark:fill-gray-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)}
{reasoningSteps.length > 0 && (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
className={`flex items-center gap-1 text-xs rounded px-2 py-1 ${
message.role === 'user'
? 'bg-white/10 hover:bg-white/20'
: 'bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 dark:text-white'
}`}
>
<Info size={12} />
<span>Reasoning ({reasoningSteps.length})</span>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-w-md z-50"
sideOffset={5}
>
<div className="max-h-[300px] overflow-y-auto">
<h4 className="font-semibold mb-2 text-gray-900 dark:text-white">Reasoning Steps:</h4>
<ul className="space-y-3">
{reasoningSteps.map((step) => (
<li key={step.id} className="text-sm">
<div className="font-medium text-gray-900 dark:text-white capitalize">
{step.type}:
</div>
<div className="text-gray-700 dark:text-gray-300 ml-2">{step.content}</div>
{step.thought && step.thought !== step.content && (
<div className="text-gray-500 dark:text-gray-400 ml-2 mt-1 text-xs">
Thought: {step.thought}
</div>
)}
</li>
))}
</ul>
</div>
<Tooltip.Arrow className="fill-white dark:fill-gray-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)}
</div>
</div>
{/* Image viewer modal */}
<ImageViewer
image={selectedImage}
isOpen={Boolean(selectedImage)}
onClose={closeImage}
allImages={images || []}
/>
</div>
);
};
const handleSubmit = async () => {
if (!inputMessage.trim() || isProcessing) return;
const currentMessage = inputMessage;
setMessages(prev => [...prev, { role: 'user', content: currentMessage }]);
setInputMessage('');
setIsProcessing(true);
// ENHANCED DEBUGGING: Log the current state of sessionId and related variables
console.log('SUBMIT DEBUG DATA:', {
sessionId,
sessionIdType: typeof sessionId,
sessionIdLength: sessionId ? sessionId.length : 0,
hasActiveConversation: !!activeConversation,
activeConversationId: activeConversation ? activeConversation.id : null,
activeConversationSessionId: activeConversation ? activeConversation.session_id : null,
localStorageSessionId: localStorage.getItem('chatSessionId')
});
// Check if this is a blank chat with no active conversation - create one if needed
if (!activeConversation) {
logToConsole('info', 'No active conversation found when submitting message, creating a new one', { sessionId });
try {
await handleCreateNewConversation();
} catch (err) {
console.error('Error creating new conversation before sending message:', err);
// Continue with the chat request anyway, as the backend might handle this
}
}
// ENHANCEMENT: Try to ensure we have a valid sessionId
const effectiveSessionId = sessionId ||
(activeConversation ? activeConversation.session_id : null) ||
localStorage.getItem('chatSessionId');
if (!effectiveSessionId) {
console.error('No valid sessionId found from any source');
setError('Session ID is missing. Please refresh the page.');
setIsProcessing(false);
return;
}
try {
// Log the sessionId to help debug
console.log('Sending chat request with sessionId:', effectiveSessionId);
const fetchOptions = getFetchDefaults();
const response = await fetchWithTimeout(`${BACKEND_URL}/chat`, {
...fetchOptions,
method: 'POST',
body: JSON.stringify({
message: currentMessage,
sessionId: effectiveSessionId
})
});
if (!response.ok) {
const errorData = await response.json();
// If the server says chat is not initialized, reset UI to show upload screen
if (errorData.error === 'Chat system not initialized') {
setIsInitialized(false);
throw new Error('Chat system not initialized. Please upload files first.');
}
throw new Error(errorData.error || 'Failed to get response');
}
const responseData = await response.json();
let assistantMessage;
if (responseData.status === 'success' && responseData.data) {
assistantMessage = {
role: 'assistant',
content: responseData.data.response || '',
sources: responseData.data.sources || [],
reasoning: responseData.data.reasoning || [],
images: responseData.data.images || []
};
} else if (responseData.result) {
assistantMessage = {
role: 'assistant',
content: responseData.result.response || '',
sources: responseData.result.sources || [],
reasoning: responseData.result.reasoning || [],
images: responseData.result.images || []
};
} else {
assistantMessage = {
role: 'assistant',
content: responseData.response || '',
sources: responseData.sources || [],
reasoning: responseData.reasoning || [],
images: responseData.images || []
};
}
console.log('Processed assistant message:', assistantMessage);
setMessages(prev => [...prev, assistantMessage]);
setError(null);
// Update the conversations list to get the AI-generated title
// Add a longer delay to allow backend to generate title using OpenAI
setTimeout(() => {
// Need a longer delay for OpenAI to generate the title
updateConversationsList();
// Check again after a bit longer in case the first attempt was too soon
setTimeout(() => {
updateConversationsList();
}, 3000); // Check again after 3 more seconds
}, 2000); // Initial 2 second delay
} catch (e) {
console.error('Error in chat:', e);
setError('Failed to process response. Please try again.');
} finally {
setIsProcessing(false);
}
};
// Helper function to slice file into chunks and upload them
const uploadFileInChunks = async (file, fileType, sessionId, controller) => {
const chunkSize = 1024 * 1024; // 1MB chunks
const totalChunks = Math.ceil(file.size / chunkSize);
// Step 1: Initialize the chunked upload
const fetchOptions = getFetchDefaults();
const initResponse = await fetch(`${BACKEND_URL}/init-chunked-upload`, {
method: 'POST',
headers: {
...fetchOptions.headers,
'Content-Type': 'application/json'
},
signal: controller.signal,
body: JSON.stringify({
fileName: file.name,
fileType: fileType, // 'brief' or 'supporting'
fileSize: file.size,
mimeType: file.type,
totalChunks,
sessionId
})
});
if (!initResponse.ok) {
const error = await initResponse.json();
throw new Error(error.message || 'Failed to initialize chunked upload');
}
const { uploadId } = await initResponse.json();
// Step 2: Upload each chunk
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const chunkFormData = new FormData();
chunkFormData.append('chunk', chunk);
chunkFormData.append('uploadId', uploadId);
chunkFormData.append('chunkIndex', chunkIndex);
const fetchOptions = getFetchDefaults();
const chunkResponse = await fetch(`${BACKEND_URL}/upload-chunk`, {
method: 'POST',
body: chunkFormData,
signal: controller.signal,
headers: {
...fetchOptions.headers,
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!chunkResponse.ok) {
throw new Error(`Failed to upload chunk ${chunkIndex}`);
}
}
// Step 3: Finalize the upload
const finalizeOptions = getFetchDefaults();
const finalizeResponse = await fetch(`${BACKEND_URL}/finalize-upload`, {
method: 'POST',
headers: {
...finalizeOptions.headers,
'Content-Type': 'application/json'
},
signal: controller.signal,
body: JSON.stringify({ uploadId })
});
if (!finalizeResponse.ok) {
throw new Error('Failed to finalize upload');
}
return { fileName: file.name, uploadId };
};
// Main function to initialize chat
const initializeChat = async () => {
if (briefFiles.length === 0) {
setError('Please upload at least one brief file.');
return;
}
logToConsole('info', 'Starting chat initialization', {
briefFilesCount: briefFiles.length,
supportingFilesCount: supportingFiles.length
});
setIsProcessing(true);
// Create an AbortController for proper timeout handling
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
logToConsole('error', 'Request timed out after 10 minutes');
}, 600000); // 10 minute timeout
try {
// Check for large files that might need chunked upload
const largeFiles = [...briefFiles, ...supportingFiles].filter(file => file.size > 1024 * 1024);
if (largeFiles.length > 0) {
logToConsole('info', 'Large files detected, using chunked upload approach', {
largeFiles: largeFiles.map(f => ({ name: f.name, size: f.size }))
});
}
// Upload all brief files
const briefUploads = [];
for (const file of briefFiles) {
if (file.size > 1024 * 1024) { // If file is larger than 1MB, use chunked upload
const result = await uploadFileInChunks(file, 'brief', sessionId, controller);
briefUploads.push(result);
} else {
// For small files, use regular upload
const smallFormData = new FormData();
smallFormData.append('file', file);
smallFormData.append('fileType', 'brief');
smallFormData.append('sessionId', sessionId);
const fetchOptions = getFetchDefaults();
const response = await fetch(`${BACKEND_URL}/upload-small-file`, {
method: 'POST',
body: smallFormData,
signal: controller.signal,
headers: {
...fetchOptions.headers,
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`Failed to upload small file: ${file.name}`);
}
const result = await response.json();
briefUploads.push(result);
}
}
// Upload all supporting files (similar approach)
const supportingUploads = [];
for (const file of supportingFiles) {
if (file.size > 1024 * 1024) {
const result = await uploadFileInChunks(file, 'supporting', sessionId, controller);
supportingUploads.push(result);
} else {
// Small file upload code similar to brief files
const smallFormData = new FormData();
smallFormData.append('file', file);
smallFormData.append('fileType', 'supporting');
smallFormData.append('sessionId', sessionId);
const fetchOptions = getFetchDefaults();
const response = await fetch(`${BACKEND_URL}/upload-small-file`, {
method: 'POST',
body: smallFormData,
signal: controller.signal,
headers: {
...fetchOptions.headers,
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`Failed to upload small file: ${file.name}`);
}
const result = await response.json();
supportingUploads.push(result);
}
}
// Now that all files are uploaded, initialize the chat
const finalizeData = {
sessionId,
briefFiles: briefUploads,
supportingFiles: supportingUploads
};
const finalizeOptions = getFetchDefaults();
const finalizeResponse = await fetch(`${BACKEND_URL}/initialize-from-uploads`, {
method: 'POST',
headers: {
...finalizeOptions.headers,
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(finalizeData),
signal: controller.signal
});
// Clear the timeout
clearTimeout(timeoutId);
if (!finalizeResponse.ok) {
const errorData = await finalizeResponse.json();
throw new Error(errorData.error || `Server responded with ${finalizeResponse.status}`);
}
const data = await finalizeResponse.json();
logToConsole('info', 'Chat initialization successful');
setIsInitialized(true);
setMessages([{
role: 'assistant',
content: 'Chat initialized! How can I help you?'
}]);
setError(null);
} catch (err) {
// Clear the timeout if there's an error
clearTimeout(timeoutId);
// Enhanced error logging
const errorDetails = {
message: err.message,
name: err.name,
stack: err.stack,
isAbortError: err.name === 'AbortError'
};
logToConsole('error', 'Initialization error', errorDetails);
console.error('Initialization error:', err);
// Set appropriate error message for user
if (err.name === 'AbortError') {
setError('Request timed out. The file(s) may be too large.');
} else {
setError(err.message || 'Failed to initialize chat');
}
setIsInitialized(false);
} finally {
setIsProcessing(false);
}
};
return (
<Tooltip.Provider style={{ border: 'none' }}>
<div className="flex min-h-screen w-full bg-black no-borders">
{/* Conversation Sidebar - Fixed to left side */}
<div
ref={sidebarRef}
className="bg-gray-200 dark:bg-gray-800 shadow-lg flex flex-col h-full fixed left-0 top-0 bottom-0 z-10"
style={{ width: sidebarWidth }}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between bg-white dark:bg-gray-900">
<h2 className="font-bold text-lg dark:text-white">Conversations</h2>
<button
onClick={handleCreateNewConversation}
className="p-2 bg-blue-500 text-white rounded-full hover:bg-blue-600"
title="New Conversation"
>
<PlusCircle size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{loadingConversations ? (
<div className="flex justify-center py-4">
<Loader2 className="animate-spin dark:text-white" size={24} />
</div>
) : conversations.length > 0 ? (
conversations.map(convo => (
<div
key={convo.id}
className={`px-3 py-2 mb-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 ${
activeConversation && activeConversation.id === convo.id
? 'bg-gray-200 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-700'
: ''
}`}
>
<div className="flex items-center justify-between">
<div
className="flex-1 cursor-pointer pr-2"
onClick={() => handleLoadConversation(convo)}
>
<div className="font-medium truncate text-sm dark:text-white">{convo.title || "New conversation"}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{new Date(convo.last_updated).toLocaleDateString()}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
// Ask for confirmation before deleting
if (window.confirm(`Are you sure you want to delete "${convo.title || "this conversation"}"?`)) {
deleteConversation(
convo.id,
getCurrentUser(),
setConversations,
setError
);
}
}}
className="flex-shrink-0 p-1.5 text-gray-500 hover:text-red-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full dark:text-gray-400 dark:hover:text-red-400"
title="Delete conversation"
>
<Trash2 size={16} />
</button>
</div>
</div>
))
) : (
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
No conversations yet
</div>
)}
</div>
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-300">{getCurrentUser()}</span>
<button
onClick={signOut}
className="px-2 py-1 bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 text-xs"
>
Sign Out
</button>
</div>
{!autoWidth && (
<button
onClick={() => {
setAutoWidth(true);
}}
className="text-xs text-center w-full px-2 py-1 mt-2 bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
title="Reset sidebar to auto-adjust width"
>
Auto-adjust width
</button>
)}
</div>
</div>
</div>
{/* Resize handle */}
<div
className="w-1 hover:w-2 bg-gray-400 hover:bg-blue-500 cursor-ew-resize fixed top-0 bottom-0 z-20 transition-all duration-150"
style={{ left: `${sidebarWidth}px` }}
onMouseDown={startResizing}
></div>
{/* Header with HP branding - completely rebuilt without any borders */}
<div
className="fixed top-0 w-full bg-black py-6 z-30 flex flex-col items-center justify-center no-borders"
style={{
marginLeft: `${sidebarWidth}px`,
width: `calc(100% - ${sidebarWidth}px)`,
boxShadow: 'none'
}}
>
{/* Theme toggle in top right corner */}
<div style={{ position: 'absolute', top: '1rem', right: '1rem' }}>
<ThemeToggle />
</div>
{/* HP Logo */}
<div className="h-14 mb-2 no-borders flex items-center justify-center text-white font-bold text-4xl">
HP
</div>
<h1 className="text-white font-bold text-2xl tracking-widest mt-1 uppercase no-borders">Marketing Bot</h1>
</div>
{/* Main Chat Area - Centered with left margin to account for sidebar */}
<div className="flex-1 pt-36 pb-8 px-4 no-borders" style={{ marginLeft: `${sidebarWidth}px` }}>
<div className="max-w-4xl w-full mx-auto bg-white dark:bg-gray-800 rounded-lg shadow-xl flex flex-col no-borders">
<div className="flex-1 flex flex-col p-6">
{isCheckingStatus ? (
<div className="flex flex-col items-center justify-center h-full">
<Loader2 className="animate-spin h-12 w-12 text-blue-500 mb-4" />
<p className="text-lg dark:text-white">Connecting to HP Marketing Materials Chatbot...</p>
</div>
) : !isInitialized ? (
<div className="flex flex-col items-center justify-center h-full">
<h1 className="text-2xl font-bold mb-4 dark:text-white">HP Marketing Materials Chatbot</h1>
<p className="text-lg text-center mb-4 dark:text-gray-300">
The system is currently initializing. Please try again later.
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Refresh
</button>
</div>
) : (
<>
<div className="border-b pb-4 mb-4">
<div className="flex justify-between items-center mb-2">
<h1 className="text-2xl font-bold dark:text-white">
{activeConversation?.title || "HP Marketing Materials Chatbot"}
</h1>
<div className="flex gap-2">
<button
onClick={resetChat}
className="px-3 py-1 bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 text-sm"
>
Reset Chat
</button>
</div>
</div>
<p className="text-gray-600 dark:text-gray-300">
Ask questions about HP's marketing materials, branding guidelines, campaign strategies, and creative processes.
</p>
</div>
<div className="flex-1 overflow-y-auto mb-4 -mx-6 px-6">
<div className="space-y-4 py-2">
{messages.map((message, index) => (
<MessageBubble key={index} message={message} />
))}
<div ref={messagesEndRef} className="h-4" />
</div>
</div>
<div className="flex space-x-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Prevent any default behavior
if (!isProcessing && inputMessage.trim()) {
handleSubmit();
}
}
}}
placeholder="Type your message..."
className="flex-1 p-2 border dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isProcessing}
/>
<button
onClick={handleSubmit}
disabled={isProcessing || !inputMessage.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{isProcessing ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Send size={16} />
)}
</button>
</div>
</>
)}
{error && !isCheckingStatus && (
<Alert variant="destructive" className="mt-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
</div>
</div>
</div>
</Tooltip.Provider>
);
}