Spaces:
Running
Running
| 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 ( | |
| <div className="chat-page-with-sidebar"> | |
| {/* Sidebar Component */} | |
| <Sidebar | |
| user={user} | |
| currentSessionId={currentSessionId} | |
| onSelectSession={handleSelectSession} | |
| onNewChat={handleNewChat} | |
| onCurrentSessionDeleted={handleCurrentSessionDeleted} | |
| onSignOut={onSignOut} | |
| authToken={authToken} | |
| onSidebarToggle={handleSidebarToggle} | |
| isMobileOpen={isMobileMenuOpen} | |
| onMobileToggle={setIsMobileMenuOpen} | |
| onNavigateToCanvas={onNavigateToCanvas} | |
| refreshTrigger={sidebarRefreshTrigger} | |
| userAvatarId={userAvatarId} | |
| onAvatarChange={handleAvatarChange} | |
| onOpenProfile={() => setShowProfileForm(true)} | |
| onOpenAccount={() => setShowAccount(true)} | |
| onOpenClearData={() => setShowClearData(true)} | |
| /> | |
| <div className={`main-chat-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}> | |
| <div className="modern-chat-page"> | |
| <AppHeader | |
| currentPage="chat" | |
| onNavigateToHome={onNavigateToHome} | |
| onNavigateToChat={() => {}} | |
| onNavigateToCanvas={onNavigateToCanvas} | |
| onMobileMenu={handleMobileMenuToggle} | |
| > | |
| <AdvisorStatusDropdown | |
| advisors={advisors} | |
| activeAdvisorIds={activeAdvisorIds} | |
| onToggleAdvisor={handleToggleAdvisor} | |
| onSetActiveAdvisors={handleSetActiveAdvisors} | |
| thinkingAdvisors={thinkingAdvisors} | |
| getAdvisorColors={getAdvisorColors} | |
| isDark={isDark} | |
| /> | |
| <ExportButton | |
| hasMessages={hasConversationMessages} | |
| currentSessionId={currentSessionId} | |
| authToken={authToken} | |
| /> | |
| {/* User guide button (from main) — slotted into AppHeader's children */} | |
| <button | |
| className="icon-btn header-help-btn" | |
| onClick={() => window.dispatchEvent(new CustomEvent('open-user-guide'))} | |
| title="Open user guide" | |
| > | |
| <HelpCircle size={18} /> | |
| </button> | |
| </AppHeader> | |
| {/* Main Content */} | |
| <div className="chat-content"> | |
| {!hasMessages ? ( | |
| <div className="welcome-state"> | |
| <AdvisorCarousel /> | |
| <SuggestionsPanel onSuggestionClick={handleSendMessage} /> | |
| </div> | |
| ) : ( | |
| <div className="messages-container"> | |
| {/* Add loading session indicator */} | |
| {isLoadingSession && ( | |
| <div className="loading-session"> | |
| <div className="loading-spinner"></div> | |
| <span>Loading chat session...</span> | |
| </div> | |
| )} | |
| <div className="messages-list"> | |
| <div className="messages-scroll"> | |
| {messageGroups.map((group) => ( | |
| group.type === 'advisor_group' ? ( | |
| <AdvisorCarousel | |
| key={group.messages.map(m => m.id).join('-')} | |
| messages={group.messages} | |
| onReply={handleReplyToMessage} | |
| onExpand={handleExpandMessage} | |
| onClick={handleMessageClick} | |
| /> | |
| ) : ( | |
| <div key={group.message.id}> | |
| {group.message.type === 'user' && ( | |
| <div className="user-message-container"> | |
| <div className="user-message"> | |
| {group.message.replyTo && ( | |
| <div className="reply-indicator"> | |
| <Reply size={12} /> | |
| <span>Reply to {group.message.replyTo.advisorName}</span> | |
| </div> | |
| )} | |
| <p>{group.message.content}</p> | |
| </div> | |
| </div> | |
| )} | |
| {group.message.type === 'error' && ( | |
| <div className="error-message-container"> | |
| <div className="error-message"> | |
| <p>{group.message.content}</p> | |
| </div> | |
| </div> | |
| )} | |
| {group.message.type === 'system' && ( | |
| <div className="system-message-container"> | |
| <div className="system-message"> | |
| <p>{group.message.content}</p> | |
| </div> | |
| </div> | |
| )} | |
| {group.message.type === 'document_upload' && ( | |
| <div className="system-message-container"> | |
| <div className="system-message document-upload"> | |
| <FileText size={16} /> | |
| <p>{group.message.content}</p> | |
| </div> | |
| </div> | |
| )} | |
| {group.message.type === 'clarification' && ( | |
| <div className="clarification-message-container"> | |
| <div className="clarification-message"> | |
| <div className="clarification-header"> | |
| <MessageCircle size={16} /> | |
| <span>I need a bit more information</span> | |
| </div> | |
| <p>{group.message.content}</p> | |
| {group.message.suggestions && group.message.suggestions.length > 0 && ( | |
| <div className="clarification-suggestions"> | |
| <p className="suggestions-label">Here are some ways you could be more specific:</p> | |
| <div className="suggestions-list"> | |
| {group.message.suggestions.map((suggestion, index) => ( | |
| <button | |
| key={index} | |
| className="suggestion-button" | |
| onClick={() => handleSendMessage(suggestion)} | |
| > | |
| {suggestion} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| ))} | |
| {thinkingAdvisors.includes('system') && ( | |
| <div className="orchestrator-thinking"> | |
| <div className="thinking-bubble"> | |
| <MessageCircle size={20} /> | |
| </div> | |
| <div className="thinking-content"> | |
| <span className="thinking-label">Orchestrator is thinking...</span> | |
| <div className="thinking-animation"> | |
| <div className="dot"></div> | |
| <div className="dot"></div> | |
| <div className="dot"></div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {thinkingAdvisors.filter(id => id !== 'system').map(advisorId => ( | |
| <ThinkingIndicator key={advisorId} advisorId={advisorId} /> | |
| ))} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <div className="floating-input-area"> | |
| {replyingTo && ( | |
| <div className="reply-banner"> | |
| <div className="reply-info"> | |
| <Reply size={16} /> | |
| <span>Replying to <strong>{replyingTo.advisorName}</strong></span> | |
| </div> | |
| <button onClick={cancelReply} className="cancel-reply"> | |
| <X size={16} /> | |
| </button> | |
| </div> | |
| )} | |
| <EnhancedChatInput | |
| onSendMessage={handleInputSubmit} | |
| onFileUploaded={handleFileUploaded} | |
| uploadedDocuments={uploadedDocuments} | |
| isLoading={isLoading} | |
| currentChatSessionId={currentSessionId} | |
| authToken={authToken} | |
| placeholder={ | |
| replyingTo | |
| ? `Reply to ${replyingTo.advisorName}...` | |
| : chatPlaceholder | |
| } | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ChatPage; | |