import { useState, useRef, useEffect } from 'react'; import { MessageSquare, MessageCircle, ArrowDown, Bot, Settings, Zap, BarChart3, Paperclip } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Input } from '@/components/ui/input'; import MentionInput from '@/components/ui/MentionInput'; import { parseMentions, type ParsedMentions } from '@/utils/mentionUtils'; import ChatMessage from '@/components/ChatMessage'; import ReasoningPanel from './ReasoningPanel'; import { Persona } from '@/types/persona'; import { ModeEvent, Message } from './types'; import { focusGroupsApi, focusGroupAiApi } from '@/lib/api'; import { toast } from 'sonner'; import ModeSwitchMarker from './ModeSwitchMarker'; interface DiscussionPanelProps { messages: Message[]; modeEvents: ModeEvent[]; personas: Persona[]; isSpeaking: boolean; focusGroupId: string; isAiModeActive?: boolean; // Whether the focus group is in AI mode (shows continuous loading when true) selectedParticipantIds: string[]; onToggleHighlight: (messageId: string) => void; onAdvanceDiscussion: () => void; onNewMessage: (message: Message) => void; onStatusChange?: () => void; // Optional callback for when focus group status changes isEditingDiscussionGuide?: boolean; // Whether the discussion guide is being edited } const DiscussionPanel = ({ messages, modeEvents, personas, isSpeaking, focusGroupId, isAiModeActive = false, selectedParticipantIds, onToggleHighlight, onAdvanceDiscussion, onNewMessage, onStatusChange, isEditingDiscussionGuide = false }: DiscussionPanelProps) => { const [userInput, setUserInput] = useState(''); const [currentMentions, setCurrentMentions] = useState(null); const [isTyping, setIsTyping] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); // Track the last persona who responded for round-robin approach const [lastRespondentIndex, setLastRespondentIndex] = useState(-1); // Track when we're expecting new messages to arrive const [isExpectingMessage, setIsExpectingMessage] = useState(false); const previousMessageCountRef = useRef(0); const loadingStartTimeRef = useRef(null); const minimumLoadingDurationRef = useRef(10000); // 10 seconds minimum const messagesEndRef = useRef(null); // Disabled auto-scroll by default const [autoScroll, setAutoScroll] = useState(false); // Autonomous conversation state const [showAutonomousControls, setShowAutonomousControls] = useState(false); const [autonomousLoading, setAutonomousLoading] = useState(false); // Local AI mode state for immediate updates during start/stop operations const [localAiModeActive, setLocalAiModeActive] = useState(null); // Use local state if available, otherwise fall back to prop const effectiveAiModeActive = localAiModeActive !== null ? localAiModeActive : isAiModeActive; // Reasoning panel state const [reasoningHistory, setReasoningHistory] = useState([]); const [reasoningPanelExpanded, setReasoningPanelExpanded] = useState(false); // Calculate reasoning panel visibility - only show when user explicitly expands it const reasoningPanelVisible = reasoningPanelExpanded; // Fetch reasoning history when in AI mode useEffect(() => { if (isAiModeActive && focusGroupId) { checkAutonomousStatus(); } }, [isAiModeActive, focusGroupId]); // Check autonomous conversation status const checkAutonomousStatus = async () => { if (!focusGroupId) return; try { // Status is managed by parent component through isAiModeActive prop // Just fetch reasoning history if AI mode is active if (isAiModeActive) { fetchReasoningHistory(); } } catch (error) { console.error('Error checking autonomous status:', error); } }; // Fetch reasoning history const fetchReasoningHistory = async () => { if (!focusGroupId) return; try { const response = await focusGroupAiApi.getReasoningHistory(focusGroupId); setReasoningHistory(response.data.reasoning_history || []); // Reasoning panel visibility is now calculated automatically based on isAiModeActive and reasoning history } catch (error) { console.error('Error fetching reasoning history:', error); } }; // Only auto-scroll to bottom when new messages arrive if enabled useEffect(() => { if (autoScroll) { scrollToBottom(); } }, [messages, autoScroll]); // Polling for reasoning history and status when AI mode is active useEffect(() => { let interval: NodeJS.Timeout; if (isAiModeActive && focusGroupId) { // Poll more frequently for reasoning updates and status sync (every 5 seconds) interval = setInterval(() => { fetchReasoningHistory(); checkAutonomousStatus(); // Also check if status has changed }, 5000); } return () => { if (interval) { clearInterval(interval); } }; }, [isAiModeActive, focusGroupId]); // Initialize message count reference useEffect(() => { previousMessageCountRef.current = messages.length; }, []); // Detect when new messages arrive and clear loading state useEffect(() => { const currentMessageCount = messages.length; const previousCount = previousMessageCountRef.current; // If we're expecting a message and the count increased, check minimum duration if (isExpectingMessage && currentMessageCount > previousCount) { const now = Date.now(); const loadingStartTime = loadingStartTimeRef.current; if (loadingStartTime && (now - loadingStartTime) >= minimumLoadingDurationRef.current) { // Minimum duration has passed, safe to clear loading setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; } else if (loadingStartTime) { // Wait for minimum duration to complete const remainingTime = minimumLoadingDurationRef.current - (now - loadingStartTime); setTimeout(() => { setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; }, Math.max(0, remainingTime)); } else { // No start time recorded, clear immediately setIsTyping(false); setIsExpectingMessage(false); } } // Update the reference for next comparison previousMessageCountRef.current = currentMessageCount; }, [messages.length, isExpectingMessage]); // Get persona info by ID const getPersona = (id: string) => { return personas.find(p => p.id === id || p._id === id); }; // Filter messages based on selected participants const filteredMessages = selectedParticipantIds.length === 0 ? messages // Show all messages when no participants are selected : messages.filter(message => message.senderId === 'moderator' || // Always show AI moderator messages message.senderId === 'facilitator' || // Always show human facilitator messages selectedParticipantIds.includes(message.senderId) // Show selected participants' messages ); // Merge messages and mode events by timestamp for chronological display const getTimelineItems = () => { const timeline: Array<{ type: 'message' | 'mode_event'; data: Message | ModeEvent; timestamp: Date }> = []; // Add messages to timeline filteredMessages.forEach(message => { timeline.push({ type: 'message', data: message, timestamp: message.timestamp }); }); // Add mode events to timeline modeEvents.forEach(event => { timeline.push({ type: 'mode_event', data: event, timestamp: event.timestamp }); }); // Sort by timestamp return timeline.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); }; // Helper function to scroll to bottom, keeping messages visible const scrollToBottom = () => { // Don't scroll if editing discussion guide if (isEditingDiscussionGuide) return; if (messagesEndRef.current) { // With Radix ScrollArea, we need to find the viewport element const scrollViewport = messagesEndRef.current.closest('[data-radix-scroll-area-viewport]'); if (scrollViewport) { // Calculate a position that shows the end marker at the bottom of the view // Add extra padding (100px) to ensure it doesn't scroll too far const newScrollTop = messagesEndRef.current.offsetTop - scrollViewport.clientHeight + 50; // Smooth scroll using animation const startPosition = scrollViewport.scrollTop; const distance = newScrollTop - startPosition; const duration = 300; // ms let startTime: number | null = null; // Animate the scroll const animateScroll = (timestamp: number) => { if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; const progress = Math.min(elapsed / duration, 1); // Ease-out function for smoother stop const easeProgress = 1 - Math.pow(1 - progress, 3); scrollViewport.scrollTop = startPosition + distance * easeProgress; if (progress < 1) { window.requestAnimationFrame(animateScroll); } }; window.requestAnimationFrame(animateScroll); } else { // Fallback for browsers without requestAnimationFrame or if can't find viewport messagesEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); } } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!userInput.trim()) return; let finalMessageText = userInput; let uploadedFilename: string | null = null; let uploadedAssetMetadata: { filename: string; displayReference: string } | null = null; // Store current mentions for response generation const mentionsToProcess = currentMentions; // Clear input and show typing indicator setUserInput(''); setCurrentMentions(null); setIsTyping(true); setIsExpectingMessage(true); loadingStartTimeRef.current = Date.now(); try { // Handle file upload if a file is selected if (selectedFile) { try { toast.info('Uploading creative asset...', { description: 'Please wait while we upload your image.' }); const formData = new FormData(); formData.append('assets', selectedFile); const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData); console.log('Upload response:', uploadResponse?.data); // Parse the actual backend response structure const responseData = uploadResponse?.data; if (responseData && responseData.assets && responseData.assets.length > 0) { // Backend returns: { assets: [{ filename: 'fg-...', original_name: '...', ... }] } uploadedFilename = responseData.assets[0].filename; console.log('Successfully got filename from upload response:', uploadedFilename); } else { console.error('Invalid upload response structure:', responseData); } if (uploadedFilename) { // Get the latest asset count to generate display reference try { const assetsResponse = await focusGroupsApi.getAssets(focusGroupId); const allAssets = assetsResponse?.data?.assets || []; // Find our newly uploaded asset const uploadedAsset = allAssets.find(asset => asset.filename === uploadedFilename); // Generate display reference let displayReference = 'the uploaded asset'; if (uploadedAsset) { if (uploadedAsset.user_assigned_name) { displayReference = uploadedAsset.user_assigned_name; } else { // Count assets to generate "Asset N" reference const assetIndex = allAssets.findIndex(asset => asset.filename === uploadedFilename); displayReference = `Asset ${assetIndex + 1}`; } } // Store asset metadata for message uploadedAssetMetadata = { filename: uploadedFilename, displayReference: displayReference }; // Format message text to include the display reference instead of filename finalMessageText = `Please review ${displayReference}. ${userInput}`; console.log('Using display reference in message:', displayReference); } catch (assetError) { console.error('Error fetching asset metadata:', assetError); // Fallback to generic reference finalMessageText = `Please review the uploaded asset. ${userInput}`; // Still store the basic metadata uploadedAssetMetadata = { filename: uploadedFilename, displayReference: 'the uploaded asset' }; } toast.success('Creative asset uploaded successfully', { description: 'The image has been attached to your message.' }); } } catch (uploadError) { console.error('Error uploading file:', uploadError); console.error('Upload error details:', uploadError.response?.data); toast.error('Failed to upload creative asset', { description: 'Your message will be sent without the attachment.' }); // Continue with message sending even if upload fails } // Clear the selected file after upload attempt (successful or not) clearSelectedFile(); } // Send message to API first to get server timestamp const messageData: any = { text: finalMessageText, type: 'question', senderId: 'facilitator' }; // Add visual asset information if file was uploaded if (uploadedFilename) { messageData.attached_assets = [uploadedFilename]; messageData.activates_visual_context = true; // Add visual asset metadata for database storage if (uploadedAssetMetadata) { messageData.visualAsset = uploadedAssetMetadata; } } const response = await focusGroupsApi.sendMessage(focusGroupId, messageData); console.log('Message sent to API:', response); // Message will be handled by WebSocket system with correct server timestamp // No need to manually create and add message here // Scroll to the latest message when the user sends something // regardless of auto-scroll setting setTimeout(() => { scrollToBottom(); }, 100); // Check if there are mentions to process if (mentionsToProcess && mentionsToProcess.mentionedParticipantIds.length > 0) { // Generate responses from mentioned participants setTimeout(() => { generateMentionedResponses( mentionsToProcess.mentionedParticipantIds, finalMessageText ); }, 500); } else { // No mentions to process - clear typing indicator immediately since no AI responses are expected setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; } } catch (error) { console.error('Error sending message:', error); setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; // Create fallback message with original text const fallbackMessage: Message = { id: `msg-${Date.now()}`, senderId: 'facilitator', text: userInput, // Use original input, not processed text timestamp: new Date(), type: 'question' }; // Update UI with the fallback message onNewMessage(fallbackMessage); // Scroll to see the user's message even on error setTimeout(() => { scrollToBottom(); }, 100); toast.error('Failed to send message to server', { description: 'Message will be shown locally but not saved.' }); } }; // Get the most recent moderator message const getLastModeratorMessage = (): string => { // Look for moderator questions, then system messages, or use a default for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].senderId === 'moderator' && messages[i].type === 'question') { return messages[i].text; } } // As a fallback, look for any moderator or system message for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].senderId === 'moderator') { return messages[i].text; } } // Default if no moderator messages found return "What are your thoughts on this topic?"; }; // Helper function to find the next item in the discussion guide const findNextDiscussionItem = (discussionGuide: any, currentPosition: any) => { if (!discussionGuide || !discussionGuide.sections || !currentPosition) { return null; } const { section_index, subsection_index, item_index, item_type } = currentPosition; const sections = discussionGuide.sections; // Helper to get all items from a section or subsection const getItemsFromContainer = (container: any) => { const items = []; if (container.questions) { container.questions.forEach((q: any, idx: number) => { items.push({ ...q, type: 'question', index: idx }); }); } if (container.activities) { container.activities.forEach((a: any, idx: number) => { items.push({ ...a, type: 'activity', index: idx }); }); } return items.sort((a, b) => { // Sort by type first (questions before activities), then by index if (a.type !== b.type) { return a.type === 'question' ? -1 : 1; } return a.index - b.index; }); }; // Find current section if (section_index >= sections.length) { return { completed: true }; } const currentSection = sections[section_index]; // If we're in a subsection if (subsection_index !== undefined && currentSection.subsections) { if (subsection_index >= currentSection.subsections.length) { // Move to next section return findNextDiscussionItem(discussionGuide, { section_index: section_index + 1, subsection_index: undefined, item_index: 0, item_type: 'question' }); } const currentSubsection = currentSection.subsections[subsection_index]; const items = getItemsFromContainer(currentSubsection); // Find current item and get next const currentItemIndex = items.findIndex(item => item.type === item_type && item.index === item_index ); if (currentItemIndex < items.length - 1) { // Next item in same subsection const nextItem = items[currentItemIndex + 1]; return { sectionId: currentSection.id, itemId: nextItem.id, content: nextItem.content, section: currentSection, subsection: currentSubsection, item: nextItem, position: { section_index, subsection_index, item_index: nextItem.index, item_type: nextItem.type } }; } else { // Move to next subsection return findNextDiscussionItem(discussionGuide, { section_index, subsection_index: subsection_index + 1, item_index: 0, item_type: 'question' }); } } else { // We're at section level const items = getItemsFromContainer(currentSection); if (items.length > 0) { // Find current item and get next const currentItemIndex = items.findIndex(item => item.type === item_type && item.index === item_index ); if (currentItemIndex < items.length - 1) { // Next item in same section const nextItem = items[currentItemIndex + 1]; return { sectionId: currentSection.id, itemId: nextItem.id, content: nextItem.content, section: currentSection, item: nextItem, position: { section_index, subsection_index: undefined, item_index: nextItem.index, item_type: nextItem.type } }; } } // Check if this section has subsections if (currentSection.subsections && currentSection.subsections.length > 0) { return findNextDiscussionItem(discussionGuide, { section_index, subsection_index: 0, item_index: 0, item_type: 'question' }); } // Move to next section return findNextDiscussionItem(discussionGuide, { section_index: section_index + 1, subsection_index: undefined, item_index: 0, item_type: 'question' }); } }; // Advance the discussion using proper discussion guide navigation const advanceDiscussion = async () => { if (!focusGroupId) return; try { setIsTyping(true); setIsExpectingMessage(true); loadingStartTimeRef.current = Date.now(); toast.info("Advancing discussion...", { description: "Moving to the next question in the discussion guide." }); // Get current moderator status and focus group data const [statusResponse, focusGroupResponse] = await Promise.all([ focusGroupAiApi.getModeratorStatus(focusGroupId), focusGroupsApi.getById(focusGroupId) ]); if (!statusResponse?.data?.status || !focusGroupResponse?.data?.discussionGuide) { throw new Error("Could not fetch moderator status or discussion guide"); } const moderatorStatus = statusResponse.data.status; const discussionGuide = focusGroupResponse.data.discussionGuide; // Check if we have a structured discussion guide if (!discussionGuide.sections) { throw new Error("Discussion guide does not have a structured format"); } // Find the next item in the discussion guide const nextItem = findNextDiscussionItem(discussionGuide, moderatorStatus.moderator_position); if (!nextItem) { throw new Error("Could not determine next discussion item"); } if (nextItem.completed) { toast.success("Discussion guide completed", { description: "All sections of the discussion guide have been covered." }); const completionMessage: Message = { id: `msg-${Date.now()}`, senderId: 'moderator', text: "We have covered all the questions in our discussion guide. Thank you all for your valuable insights and participation in this focus group session.", timestamp: new Date(), type: 'system' }; onNewMessage(completionMessage); // Clear typing state immediately since no AI response is expected setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; return; } // Update moderator position await focusGroupAiApi.setModeratorPosition( focusGroupId, nextItem.sectionId, nextItem.itemId ); // Create and send the moderator message with the next question const moderatorMessage: Message = { id: `msg-${Date.now()}`, senderId: 'moderator', text: nextItem.content, timestamp: new Date(), type: 'question' }; // Send to API first try { const msgResponse = await focusGroupsApi.sendMessage(focusGroupId, { senderId: 'moderator', text: moderatorMessage.text, type: 'question' }); // Update message ID if provided by API if (msgResponse?.data?.message_id) { moderatorMessage.id = msgResponse.data.message_id; } } catch (msgError) { console.warn("Failed to save message to API, showing locally:", msgError); } // Add the message to the UI onNewMessage(moderatorMessage); // Clear typing state immediately in manual mode since no AI response is expected setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; // Scroll to see the new message setTimeout(() => { scrollToBottom(); }, 100); toast.success("Discussion advanced", { description: `Moved to: ${nextItem.section.title}${nextItem.subsection ? ` > ${nextItem.subsection.title}` : ''}` }); // Trigger status update in parent component if (onStatusChange) { setTimeout(() => onStatusChange(), 500); } } catch (error) { console.error("Error advancing discussion:", error); toast.error("Failed to advance discussion", { description: error.message || "There was a problem advancing to the next question." }); // Clear loading state immediately on error since no message is expected setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; } // Note: Don't clear loading in finally block - let message arrival handle it }; // Start autonomous conversation const startAutonomousConversation = async () => { if (!focusGroupId) return; console.log('Starting AI Mode: setting autonomousLoading to true'); setAutonomousLoading(true); try { console.log('Starting AI Mode: calling API...'); // Add a timeout wrapper to the API call const apiCallWithTimeout = Promise.race([ focusGroupAiApi.startAutonomousConversation(focusGroupId), // No initial prompt - let AI generate based on discussion guide new Promise((_, reject) => setTimeout(() => reject(new Error('API call timeout after 30 seconds')), 30000) ) ]); const response = await apiCallWithTimeout; console.log('Starting AI Mode: API response received:', response); if (response.data.error) { toast.error('Failed to start autonomous conversation', { description: response.data.error }); setAutonomousLoading(false); return; } toast.success('Autonomous conversation started', { description: 'The AI is now managing the focus group conversation' }); // Immediately update local AI mode state for instant UI feedback setLocalAiModeActive(true); // Notify parent component of status change and wait for it to complete try { console.log('Starting AI Mode: calling onStatusChange...'); if (onStatusChange) { await onStatusChange(); console.log('Starting AI Mode: onStatusChange completed successfully'); } } catch (statusError) { console.error('Starting AI Mode: onStatusChange failed:', statusError); } // Reset loading state after status has been updated console.log('Starting AI Mode: resetting autonomousLoading to false'); setAutonomousLoading(false); // GPT-5 FIX: Don't clear AI mode state - this was tearing down WebSocket listeners // setTimeout(() => { // console.log('Starting AI Mode: clearing local AI mode state'); // setLocalAiModeActive(null); // }, 1000); // Start polling for reasoning history fetchReasoningHistory(); } catch (error) { console.error('Error starting autonomous conversation:', error); // Log the detailed error response if (error.response && error.response.data) { console.error('Backend error details:', error.response.data); } const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Please check your connection and try again'; toast.error('Failed to start autonomous conversation', { description: errorMessage }); setAutonomousLoading(false); } }; // Stop autonomous conversation const stopAutonomousConversation = async () => { if (!focusGroupId) return; console.log('Stopping AI Mode: setting autonomousLoading to true'); setAutonomousLoading(true); try { const response = await focusGroupAiApi.stopAutonomousConversation(focusGroupId, 'manual_stop'); if (response.data.error) { toast.error('Failed to stop autonomous conversation', { description: response.data.error }); setAutonomousLoading(false); return; } setReasoningHistory([]); toast.success('Autonomous conversation stopped', { description: 'You can now moderate the discussion manually' }); // Immediately update local AI mode state for instant UI feedback setLocalAiModeActive(false); // Notify parent component of status change and wait for it to complete try { console.log('Stopping AI Mode: calling onStatusChange...'); if (onStatusChange) { await onStatusChange(); console.log('Stopping AI Mode: onStatusChange completed successfully'); } } catch (statusError) { console.error('Stopping AI Mode: onStatusChange failed:', statusError); } // Reset loading state after status has been updated console.log('Stopping AI Mode: resetting autonomousLoading to false'); setAutonomousLoading(false); // GPT-5 FIX: Don't clear AI mode state - this was tearing down WebSocket listeners // setTimeout(() => { // console.log('Stopping AI Mode: clearing local AI mode state'); // setLocalAiModeActive(null); // }, 1000); } catch (error) { console.error('Error stopping autonomous conversation:', error); toast.error('Failed to stop autonomous conversation'); setAutonomousLoading(false); } }; // Manual intervention const manualIntervention = async (action: string, message?: string, participantId?: string) => { if (!focusGroupId) return; try { const response = await focusGroupAiApi.manualIntervention(focusGroupId, action, message, participantId); if (response.data.error) { toast.error('Failed to intervene', { description: response.data.error }); return; } toast.success('Intervention successful', { description: response.data.message }); // Refresh status checkAutonomousStatus(); } catch (error) { console.error('Error with manual intervention:', error); toast.error('Failed to intervene in conversation'); } }; // Handle file selection for attachment const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { // Validate file type if (!file.type.startsWith('image/')) { toast.error('Please select an image file', { description: 'Only image files (JPG, PNG, etc.) are supported for creative review.' }); return; } // Validate file size (max 10MB) if (file.size > 10 * 1024 * 1024) { toast.error('File too large', { description: 'Please select an image smaller than 10MB.' }); return; } setSelectedFile(file); toast.success(`Image selected: ${file.name}`, { description: 'The image will be attached to your next message.' }); } }; // Clear selected file const clearSelectedFile = () => { setSelectedFile(null); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; // Generate targeted responses for mentioned participants const generateMentionedResponses = async (mentionedParticipantIds: string[], topicContext: string) => { if (!focusGroupId || mentionedParticipantIds.length === 0) return; try { setIsTyping(true); setIsExpectingMessage(true); loadingStartTimeRef.current = Date.now(); toast.info("Generating responses from mentioned participants...", { description: `Generating responses from ${mentionedParticipantIds.length} mentioned participant(s).` }); // Generate responses from each mentioned participant for (const participantId of mentionedParticipantIds) { const selectedPersona = personas.find(p => (p._id || p.id) === participantId ); if (!selectedPersona) { console.warn(`Mentioned participant ${participantId} not found in focus group`); continue; } try { // Generate the response from the mentioned participant const response = await focusGroupAiApi.generateResponse( focusGroupId, participantId, topicContext || "Continue the conversation based on the latest moderator message." ); if (response?.data?.response) { console.log('Generated response from mentioned participant:', response.data); const aiMessage: Message = { id: response.data.message_id || `msg-${Date.now()}-${participantId}`, senderId: participantId, text: response.data.response, timestamp: new Date(response.data.timestamp || response.data.created_at || new Date()), type: 'response' }; onNewMessage(aiMessage); toast.success(`Response generated from ${selectedPersona.name}`, { description: response.data.response.substring(0, 100) + '...' }); } } catch (participantError) { console.error(`Error generating response from ${selectedPersona.name}:`, participantError); toast.error(`Failed to generate response from ${selectedPersona.name}`); } } // Clear typing state immediately after all mentioned responses are processed setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; } catch (error) { console.error('Error generating mentioned responses:', error); toast.error('Failed to generate responses from mentioned participants'); // Clear loading state immediately on error since no messages are expected setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; } }; // Generate an AI response using intelligent participant selection const generateAIResponse = async () => { if (!focusGroupId) return; // Make sure we have personas to respond if (personas.length === 0) { toast.error("No participants available", { description: "Add participants to the focus group before generating responses." }); return; } try { setIsTyping(true); setIsExpectingMessage(true); loadingStartTimeRef.current = Date.now(); // Use AI to decide which participant should respond next toast.info("AI is selecting participant...", { description: "Analyzing the conversation to choose the best respondent." }); const decisionResponse = await focusGroupAiApi.makeConversationDecision(focusGroupId, 0.7, 'manual'); // Check if we have a valid decision response if (!decisionResponse || !decisionResponse.data || !decisionResponse.data.decision) { throw new Error("Empty decision response from AI"); } const decision = decisionResponse.data.decision; // Check if AI decided a participant should respond if (decision.action === 'participant_respond') { const participantId = decision.details.participant_id; const topicContext = decision.details.topic_context; const reasoning = decision.reasoning; // Find the selected persona const selectedPersona = personas.find(p => (p._id || p.id) === participantId ); if (!selectedPersona) { throw new Error(`Selected participant ${participantId} not found in focus group`); } toast.info("Generating response...", { description: `AI selected ${selectedPersona.name}: ${reasoning.substring(0, 100)}${reasoning.length > 100 ? '...' : ''}` }); // Generate the response from the AI-selected participant const response = await focusGroupAiApi.generateResponse( focusGroupId, participantId, topicContext ); // Check if we have a valid response before proceeding if (!response || !response.data) { throw new Error("Empty response from API"); } // If the response was successful, the backend has already saved the message if (response?.data?.message_id && response?.data?.response) { // Create a new message object for the UI const newMessage: Message = { id: response.data.message_id, senderId: participantId, text: response.data.response, timestamp: new Date(response.data.timestamp || response.data.created_at || new Date()), type: 'response', highlighted: false }; // Add the message to the UI onNewMessage(newMessage); // Clear typing state immediately since the participant response is now available setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; // Scroll to see the AI response setTimeout(() => { scrollToBottom(); }, 100); } else { throw new Error("Failed to generate or save AI response"); } } else { // AI decided on a different action - handle or fall back to round-robin console.log("AI suggested different action:", decision.action); if (decision.action === 'moderator_speak') { toast.info("AI suggests moderator intervention", { description: `AI reasoning: ${decision.reasoning.substring(0, 100)}${decision.reasoning.length > 100 ? '...' : ''}` }); // Clear typing state since no participant response will be generated setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; return; // Don't generate participant response } // For other actions, fall back to simple participant selection toast.warning("Using fallback participant selection", { description: `AI suggested "${decision.action}" but generating participant response anyway.` }); // Simple fallback: select next participant in round-robin const nextIndex = (lastRespondentIndex + 1) % personas.length; const nextPersona = personas[nextIndex]; const currentTopic = getLastModeratorMessage(); const personaId = nextPersona._id || nextPersona.id; const response = await focusGroupAiApi.generateResponse( focusGroupId, personaId, currentTopic ); if (response?.data?.message_id && response?.data?.response) { const newMessage: Message = { id: response.data.message_id, senderId: personaId, text: response.data.response, timestamp: new Date(response.data.timestamp || response.data.created_at || new Date()), type: 'response', highlighted: false }; onNewMessage(newMessage); // Clear typing state immediately since the participant response is now available setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; setTimeout(() => { scrollToBottom(); }, 100); setLastRespondentIndex(nextIndex); } } } catch (error) { console.error("Error generating AI response:", error); toast.error("Failed to generate AI response", { description: "There was a problem connecting to the server." }); // Clear loading state immediately on error since no message is expected setIsTyping(false); setIsExpectingMessage(false); loadingStartTimeRef.current = null; } // Note: Don't clear loading in finally block - let message arrival handle it }; return (
{/* Messages area - takes remaining space after bottom panels */}
{getTimelineItems().map((item) => ( item.type === 'message' ? ( onToggleHighlight(item.data.id)} participants={personas} focusGroupId={focusGroupId} /> ) : ( ) ))} {(isTyping || isAiModeActive) && (
{isAiModeActive ? ( ) : ( )}
{isAiModeActive ? 'AI is generating next response...' : 'Generating AI response...'}
)} {/* This empty div helps with proper scroll positioning */}
{/* Place at the end of the last message to provide better scroll reference */}
{/* Manual scroll button that only appears when not at the bottom */} {!autoScroll && filteredMessages.length > 6 && (
)}
{/* AI Reasoning Panel - pinned above controls */} setReasoningPanelExpanded(!reasoningPanelExpanded)} isAiMode={isAiModeActive} /> {/* Control panel - pinned to bottom */}
{/* Show selected file indicator */} {selectedFile && (
{selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(1)} MB)
)}
{/* Hidden file input */} { setUserInput(value); setCurrentMentions(mentions || null); }} participants={personas} placeholder="Ask a question or provide guidance..." className="flex-1 min-w-0" disabled={false} /> {/* Attachment button */}

{isSpeaking ? 'Speaking...' : isAiModeActive ? 'AI mode active' : 'Manual moderation mode'}

{/* Autonomous Mode Toggle */}
{/* Show manual controls only when not in AI mode */} {!isAiModeActive && ( <> )} {/* Show AI mode status and controls */} {isAiModeActive && (
AI Active
)}
); }; export default DiscussionPanel;