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>
1525 lines
No EOL
56 KiB
JavaScript
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>
|
|
);
|
|
} |