import React, { useState, useEffect, useRef, useMemo } from 'react'; import { MessageCircle, Reply, X, Sparkles, Users, Settings2, FileText, HelpCircle } from 'lucide-react'; import EnhancedChatInput from '../components/EnhancedChatInput'; import MessageBubble from '../components/MessageBubble'; import ThinkingIndicator from '../components/ThinkingIndicator'; import SuggestionsPanel from '../components/SuggestionsPanel'; import AppHeader from '../components/AppHeader'; import AdvisorStatusDropdown from '../components/AdvisorStatusDropdown'; import ExportButton from '../components/ExportButton'; import Sidebar from '../components/Sidebar'; import { useAppConfig } from '../contexts/AppConfigContext'; import { useTheme } from '../contexts/ThemeContext'; import '../styles/ChatPage.css'; import '../styles/EnhancedChatInput.css'; import AdvisorCarousel from '../components/AdvisorCarousel'; import OnboardingChat from '../components/OnboardingChat'; import ProfileWalkthrough from '../components/ProfileWalkthrough'; import ClearDataModal from '../components/ClearDataModal'; import AccountModal from '../components/AccountModal'; const ACTIVE_ADVISORS_STORAGE_KEY = 'cybersecurityActiveAdvisorIds'; const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSignOut }) => { const { config, advisors, getAdvisorColors } = useAppConfig(); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [thinkingAdvisors, setThinkingAdvisors] = useState([]); const [activeAdvisorIds, setActiveAdvisorIds] = useState([]); const [collectedInfo, setCollectedInfo] = useState({}); const [replyingTo, setReplyingTo] = useState(null); const [currentProvider, setCurrentProvider] = useState('gemini'); const [isProviderSwitching, setIsProviderSwitching] = useState(false); const [uploadedDocuments, setUploadedDocuments] = useState([]); const messagesEndRef = useRef(null); const { isDark } = useTheme(); const [currentSessionId, setCurrentSessionId] = useState(null); const [currentSessionTitle, setCurrentSessionTitle] = useState(''); const [isSavingSession, setIsSavingSession] = useState(false); const [isLoadingSession, setIsLoadingSession] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); const [userAvatarId, setUserAvatarId] = useState( () => localStorage.getItem('userAvatarId') || (user?.avatarId ?? null) ); const avatarOptions = config?.app?.user_avatars || []; const handleAvatarChange = async (id) => { setUserAvatarId(id); localStorage.setItem('userAvatarId', id); try { await fetch(`${process.env.REACT_APP_API_URL}/auth/me`, { method: 'PATCH', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ avatarId: id }), }); } catch (e) { console.error('Failed to save avatar:', e); } }; const [showOnboarding, setShowOnboarding] = useState(false); const [showProfileForm, setShowProfileForm] = useState(false); const [showClearData, setShowClearData] = useState(false); const [showAccount, setShowAccount] = useState(false); const [userProfile, setUserProfile] = useState(null); const loadProfile = async () => { try { const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/profile`, { headers: { Authorization: `Bearer ${authToken}` }, }); if (resp.ok) setUserProfile(await resp.json()); } catch (e) { /* ignore */ } }; useEffect(() => { if (authToken) loadProfile(); }, [authToken]); useEffect(() => { const allIds = Object.keys(advisors || {}); if (allIds.length === 0) return; setActiveAdvisorIds((prev) => { let next = prev.filter((id) => allIds.includes(id)); if (next.length === 0) { try { const stored = JSON.parse(localStorage.getItem(ACTIVE_ADVISORS_STORAGE_KEY) || 'null'); if (Array.isArray(stored)) { const valid = stored.filter((id) => allIds.includes(id)); if (valid.length > 0) next = valid; } } catch { /* ignore */ } } if (next.length === 0) next = [...allIds]; if ( prev.length === next.length && prev.every((id, index) => id === next[index]) ) { return prev; } return next; }); }, [advisors]); const persistActiveAdvisorIds = (ids) => { localStorage.setItem(ACTIVE_ADVISORS_STORAGE_KEY, JSON.stringify(ids)); }; const handleSetActiveAdvisors = (ids) => { setActiveAdvisorIds(ids); persistActiveAdvisorIds(ids); }; const handleToggleAdvisor = (id) => { setActiveAdvisorIds((prev) => { const set = new Set(prev); if (set.has(id)) { if (set.size <= 1) return prev; set.delete(id); } else { set.add(id); } const next = Array.from(set); persistActiveAdvisorIds(next); return next; }); }; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; const handleMobileMenuToggle = () => { setIsMobileMenuOpen(!isMobileMenuOpen); }; useEffect(() => { scrollToBottom(); }, [messages, thinkingAdvisors]); useEffect(() => { fetchCurrentProvider(); }, []); const fetchCurrentProvider = async () => { try { const response = await fetch(`${process.env.REACT_APP_API_URL}/current-provider`); if (response.ok) { const data = await response.json(); setCurrentProvider(data.current_provider); console.log('Loaded provider:', data.current_provider, 'Available:', data.available_providers); } } catch (error) { console.error('Error fetching current provider:', error); } }; const handleProviderSwitch = async (newProvider) => { if (newProvider === currentProvider || isProviderSwitching) return; setIsProviderSwitching(true); try { const response = await fetch(`${process.env.REACT_APP_API_URL}/switch-provider`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ provider: newProvider }), }); if (response.ok) { const data = await response.json(); setCurrentProvider(newProvider); const switchMessage = { id: generateMessageId(), type: 'system', content: `✨ Switched to ${newProvider.charAt(0).toUpperCase() + newProvider.slice(1)} provider. Your advisors are now ready with the new AI model.`, timestamp: new Date() }; setMessages(prev => [...prev, switchMessage]); } else { const error = await response.json(); console.error('Failed to switch provider:', error); const errorMessage = { id: generateMessageId(), type: 'error', content: `Failed to switch to ${newProvider}: ${error.detail || 'Unknown error'}`, timestamp: new Date() }; setMessages(prev => [...prev, errorMessage]); } } catch (error) { console.error('Error switching provider:', error); const errorMessage = { id: generateMessageId(), type: 'error', content: `Error switching to ${newProvider}. Please try again.`, timestamp: new Date() }; setMessages(prev => [...prev, errorMessage]); } finally { setIsProviderSwitching(false); } }; const generateMessageId = () => { return Date.now().toString() + Math.random().toString(36).substr(2, 9); }; const createNewSession = async (firstMessage = null) => { try { const title = firstMessage ? `${firstMessage.substring(0, 30)}...` : `Chat ${new Date().toLocaleDateString()}`; const response = await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }); if (response.ok) { const newSession = await response.json(); // Update state immediately setCurrentSessionId(newSession.id); setCurrentSessionTitle(newSession.title); console.log('MongoDB session created:', newSession.id); return newSession.id; } else { console.error('Failed to create new session'); return null; } } catch (error) { console.error('Error creating new session:', error); return null; } }; // Load an existing chat session const loadChatSession = async (sessionId) => { if (!sessionId || isLoadingSession) return; setIsLoadingSession(true); try { // Use the new switch-chat endpoint that syncs context const response = await fetch(`${process.env.REACT_APP_API_URL}/switch-chat`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_session_id: sessionId }) }); if (response.ok) { const result = await response.json(); if (result.status === 'success') { setCurrentSessionId(sessionId); setCurrentSessionTitle(''); // Will be set from MongoDB data // Load the messages from the synced context const formattedMessages = result.context.messages.map(msg => ({ ...msg, timestamp: new Date(msg.timestamp), persona_id: msg.persona_id || msg.advisor || msg.advisorId })); setMessages(formattedMessages); setReplyingTo(null); setThinkingAdvisors([]); // Also get the session title from MongoDB const sessionResponse = await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${sessionId}`, { headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' } }); if (sessionResponse.ok) { const sessionData = await sessionResponse.json(); setCurrentSessionTitle(sessionData.title); } } } } catch (error) { console.error('Error loading session:', error); } finally { setIsLoadingSession(false); } }; // Save a message to the current session (optional sessionId when state is not updated yet) const saveMessageToSession = async (message, sessionIdOverride) => { const sid = sessionIdOverride || currentSessionId; if (!sid || !authToken) return; try { await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${sid}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sid, message: { ...message, timestamp: message.timestamp.toISOString() } }) }); } catch (error) { console.error('Error saving message to session:', error); } }; // Update session title based on first message const updateSessionTitle = async (sessionId, newTitle) => { if (!sessionId || !authToken) return; try { await fetch(`${process.env.REACT_APP_API_URL}/api/chat-sessions/${sessionId}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle }) }); setCurrentSessionTitle(newTitle); } catch (error) { console.error('Error updating session title:', error); } }; // Handle selecting a session from sidebar const handleSelectSession = async (sessionId) => { if (sessionId === currentSessionId) return; await loadChatSession(sessionId); }; // Sidebar deleted the currently-active chat. Clear local state without // creating a replacement session. const handleCurrentSessionDeleted = () => { setCurrentSessionId(null); setCurrentSessionTitle(''); setMessages([]); setReplyingTo(null); setThinkingAdvisors([]); setUploadedDocuments([]); }; // Handle creating new chat from sidebar const handleNewChat = async (sessionId = null) => { if (sessionId) { // Loading existing session await loadChatSession(sessionId); return; // Return early for existing session loading } else { // Creating completely new chat with fresh context try { // Step 1: Reset memory session const response = await fetch(`${process.env.REACT_APP_API_URL}/new-chat`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: `Chat ${new Date().toLocaleDateString()}` }) }); if (response.ok) { const result = await response.json(); if (result.status === 'success') { // Step 2: Immediately create MongoDB session const newSessionId = await createNewSession(`Chat ${new Date().toLocaleDateString()}`); if (newSessionId) { // Reset all state to fresh with the new session setMessages([]); setCurrentSessionId(newSessionId); // Set the new session ID immediately setCurrentSessionTitle(`Chat ${new Date().toLocaleDateString()}`); setReplyingTo(null); setThinkingAdvisors([]); setUploadedDocuments([]); console.log('New chat created with MongoDB session:', newSessionId); // Wait a bit to ensure state has updated await new Promise(resolve => setTimeout(resolve, 100)); return newSessionId; // Return the session ID for the sidebar } else { throw new Error('Failed to create MongoDB session'); } } else { throw new Error('Failed to create memory session'); } } else { throw new Error(`HTTP error: ${response.status}`); } } catch (error) { console.error('Error creating new chat:', error); // Fallback to local reset setMessages([]); setCurrentSessionId(null); setCurrentSessionTitle(''); setReplyingTo(null); setThinkingAdvisors([]); setUploadedDocuments([]); // Re-throw the error so the sidebar knows something went wrong throw error; } } }; const handleFileUploaded = async (file, uploadResult) => { // FIXED: Use the upload result data for better messaging const documentMessage = { id: generateMessageId(), type: 'document_upload', content: `Document uploaded: ${uploadResult.filename || file.name} (${uploadResult.chunks_created || 0} sections processed)`, timestamp: new Date() }; setMessages(prev => [...prev, documentMessage]); setUploadedDocuments(prev => [...prev, file]); // FIXED: Log document access info console.log('File uploaded to session:', { filename: uploadResult.filename, session_id: uploadResult.session_id, chat_session_id: uploadResult.chat_session_id, current_session_id: currentSessionId }); // Save document upload message to database if we have a current session if (currentSessionId) { await saveMessageToSession(documentMessage); } }; const handleSendMessage = async (inputMessage) => { if (!inputMessage.trim()) return; // Create user message const userMessage = { id: generateMessageId(), type: 'user', content: inputMessage, timestamp: new Date() }; // Add to local state immediately setMessages(prev => [...prev, userMessage]); // Create new session if we don't have one let sessionId = currentSessionId; if (!sessionId) { sessionId = await createNewSession(inputMessage); if (!sessionId) { console.error('Failed to create session'); return; } } saveMessageToSession(userMessage, sessionId).catch(err => console.error('Failed to persist user message:', err) ); // Update session title if this is the first message and title is generic if (messages.length === 0 && currentSessionTitle.includes('Chat ')) { const newTitle = inputMessage.length > 30 ? `${inputMessage.substring(0, 30)}...` : inputMessage; await updateSessionTitle(sessionId, newTitle); } // Set loading state setIsLoading(true); const advisorsForRequest = activeAdvisorIds.length > 0 ? activeAdvisorIds : Object.keys(advisors || {}); // Start with just the orchestrator's thinking bubble. The backend will // emit a `progress { phase: 'selected', selected_advisors: [...] }` event // naming the 3 advisors it picked, and that handler will add their // ThinkingIndicators. This prevents the brief flash of thinking indicators // for every advisor in the active pool before ranking has run. setThinkingAdvisors(['system']); try { const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, }, body: JSON.stringify({ user_input: inputMessage, response_length: 'medium', chat_session_id: sessionId, active_advisors: advisorsForRequest, }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (!line.trim()) continue; const payload = JSON.parse(line); const d = payload.data || {}; switch (payload.type) { case 'advisor': { const msg = { id: generateMessageId(), type: 'advisor', persona_id: d.persona_id, content: d.content, timestamp: new Date(), advisorName: d.persona_name || d.persona_id, used_documents: d.used_documents || false, document_chunks_used: d.document_chunks_used || 0, }; setMessages(prev => [...prev, msg]); setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); await saveMessageToSession(msg, sessionId); break; } case 'clarification': setMessages(prev => [...prev, { id: generateMessageId(), type: 'clarification', content: d.message, suggestions: d.suggestions || [], timestamp: new Date(), }]); break; case 'progress': if (d.phase === 'selected' && Array.isArray(d.selected_advisors)) { setThinkingAdvisors(prev => { const next = new Set(prev); next.add('system'); d.selected_advisors.forEach(id => next.add(id)); return Array.from(next); }); break; } if (d.phase === 'complete') { break; } if (d.persona_id != null) { setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); } break; case 'error': setMessages(prev => [...prev, { id: generateMessageId(), type: 'error', content: d.detail || 'An error occurred', timestamp: new Date(), }]); break; default: break; } } } } catch (error) { console.error('Error sending message:', error); setMessages(prev => [...prev, { id: generateMessageId(), type: 'error', content: `Failed to send message: ${error.message}`, timestamp: new Date() }]); } finally { setIsLoading(false); setThinkingAdvisors([]); setSidebarRefreshTrigger(prev => prev + 1); } }; const handleReplyToAdvisor = async (inputMessage, replyContext) => { // Ensure we have a session before proceeding let sessionId = currentSessionId; if (!sessionId) { sessionId = await createNewSession(inputMessage); if (!sessionId) { console.error('Failed to create session for reply'); return; } } const replyMessage = { id: generateMessageId(), type: 'user', content: inputMessage, replyTo: { advisorId: replyContext.persona_id, advisorName: replyContext.advisorName, messageId: replyContext.messageId }, timestamp: new Date() }; setMessages(prev => [...prev, replyMessage]); // Save reply message to database with explicit session ID await saveMessageToSession(replyMessage, sessionId); setIsLoading(true); setThinkingAdvisors([replyContext.persona_id]); try { const response = await fetch(`${process.env.REACT_APP_API_URL}/reply-to-advisor`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ user_input: inputMessage, advisor_id: replyContext.advisorId, original_message_id: replyContext.messageId, chat_session_id: sessionId // Use confirmed session ID }), }); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const data = await response.json(); if (data.type === 'advisor_reply') { const replyResponseMessage = { id: generateMessageId(), type: 'advisor', persona_id: data.persona_id, advisorName: data.persona, content: data.response, isReply: true, timestamp: new Date() }; setMessages(prev => [...prev, replyResponseMessage]); // Save advisor reply to database await saveMessageToSession(replyResponseMessage, sessionId); } } catch (error) { console.error('Error replying to advisor:', error); const errorMessage = { id: generateMessageId(), type: 'error', content: 'Sorry, I encountered an error with your reply. Please try again.', timestamp: new Date() }; setMessages(prev => [...prev, errorMessage]); // Save error message to database await saveMessageToSession(errorMessage, sessionId); } setIsLoading(false); setThinkingAdvisors([]); }; const handleCopyMessage = (messageId, content) => { // Optional: Show a toast notification or add to message history console.log(`Copied message ${messageId}: ${content.substring(0, 50)}...`); }; const handleExpandMessage = async (messageId, advisorId) => { const advisor = advisors[advisorId]; if (!advisor) return; const originalMessage = messages.find(msg => msg.id === messageId); if (!originalMessage) return; const expandPrompt = `Please expand on your previous response: "${originalMessage.content.substring(0, 100)}..." Provide more detail and depth.`; const expandMessage = { id: generateMessageId(), type: 'user', content: expandPrompt, timestamp: new Date(), isExpandRequest: true, expandsMessageId: messageId }; setMessages(prev => [...prev, expandMessage]); // Save expand request to database await saveMessageToSession(expandMessage); setIsLoading(true); setThinkingAdvisors([advisorId]); try { const response = await fetch(`${process.env.REACT_APP_API_URL}/chat/${advisorId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ user_input: expandPrompt, response_length: 'long' }), }); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const data = await response.json(); if (data.persona && data.response) { const expandedMessage = { id: generateMessageId(), type: 'advisor', persona_id: advisorId, advisorName: advisor.name, content: data.response, isExpansion: true, expandsMessageId: messageId, timestamp: new Date() }; setMessages(prev => [...prev, expandedMessage]); // Save expanded response to database await saveMessageToSession(expandedMessage); } else { const errorMessage = { id: generateMessageId(), type: 'error', content: 'Sorry, I received an unexpected response format. Please try again.', timestamp: new Date() }; setMessages(prev => [...prev, errorMessage]); // Save error message to database await saveMessageToSession(errorMessage); } } catch (error) { console.error('Error expanding message:', error); const errorMessage = { id: generateMessageId(), type: 'error', content: 'Sorry, I encountered an error while expanding the message. Please try again.', timestamp: new Date() }; setMessages(prev => [...prev, errorMessage]); // Save error message to database await saveMessageToSession(errorMessage); } setIsLoading(false); setThinkingAdvisors([]); }; const handleReplyToMessage = (message) => { const advisor = advisors[message.persona_id]; setReplyingTo({ advisorId: message.persona_id, messageId: message.id, advisorName: advisor?.name || message.advisorName || 'Advisor', persona_id: message.persona_id }); }; const handleMessageClick = (message) => { if (message.type === 'advisor') { const advisor = advisors[message.persona_id]; setReplyingTo({ advisorId: message.persona_id, messageId: message.id, advisorName: advisor?.name || message.advisorName || 'Advisor', persona_id: message.persona_id }); } }; /** Group consecutive advisor messages so we can render them in a horizontal carousel */ const messageGroups = useMemo(() => { const groups = []; let i = 0; while (i < messages.length) { if (messages[i].type === 'advisor') { const advisorGroup = []; while (i < messages.length && messages[i].type === 'advisor') { advisorGroup.push(messages[i]); i++; } groups.push({ type: 'advisor_group', messages: advisorGroup }); } else { groups.push({ type: 'single', message: messages[i] }); i++; } } return groups; }, [messages]); const handleInputSubmit = async (inputMessage) => { if (replyingTo) { // This is a reply to a specific message await handleReplyToAdvisor(inputMessage, replyingTo); } else { // This is a regular message await handleSendMessage(inputMessage); } }; const cancelReply = () => { setReplyingTo(null); }; const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const handleSidebarToggle = (isCollapsed) => { setIsSidebarCollapsed(isCollapsed); }; const hasMessages = messages.length > 0; const hasConversationMessages = messages.filter(m => m.type !== 'system' && m.type !== 'document_upload').length > 0; const chatPlaceholder = config?.chat_page?.placeholder || "Ask your advisors anything..."; return (
{group.message.content}
{group.message.content}
{group.message.content}
{group.message.content}
{group.message.content}
{group.message.suggestions && group.message.suggestions.length > 0 && (Here are some ways you could be more specific: