semblance-dev/src/components/focus-group-session/DiscussionPanel.tsx

1395 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ParsedMentions | null>(null);
const [isTyping, setIsTyping] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<number | null>(null);
const minimumLoadingDurationRef = useRef(10000); // 10 seconds minimum
const messagesEndRef = useRef<HTMLDivElement>(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<boolean | null>(null);
// Use local state if available, otherwise fall back to prop
const effectiveAiModeActive = localAiModeActive !== null ? localAiModeActive : isAiModeActive;
// Reasoning panel state
const [reasoningHistory, setReasoningHistory] = useState<any[]>([]);
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<HTMLInputElement>) => {
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 (
<div className="glass-panel rounded-xl p-4 flex flex-col h-full">
{/* Messages area - takes remaining space after bottom panels */}
<div className="flex-1 min-h-0 mb-4">
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
{getTimelineItems().map((item) => (
item.type === 'message' ? (
<ChatMessage
key={item.data.id}
message={item.data as Message}
persona={(item.data as Message).senderId !== 'moderator' && (item.data as Message).senderId !== 'facilitator' ? getPersona((item.data as Message).senderId) : null}
toggleHighlight={() => onToggleHighlight(item.data.id)}
participants={personas}
focusGroupId={focusGroupId}
/>
) : (
<ModeSwitchMarker
key={item.data.id}
modeEvent={item.data as ModeEvent}
/>
)
))}
{(isTyping || isAiModeActive) && (
<div className="flex items-center space-x-2 text-sm text-slate-500 animate-pulse">
<div className="bg-primary/10 p-2 rounded-full">
{isAiModeActive ? (
<Bot className="h-4 w-4 text-primary animate-spin" />
) : (
<MessageCircle className="h-4 w-4 text-primary" />
)}
</div>
<span>
{isAiModeActive ? 'AI is generating next response...' : 'Generating AI response...'}
</span>
</div>
)}
{/* This empty div helps with proper scroll positioning */}
<div className="h-8"></div>
{/* Place at the end of the last message to provide better scroll reference */}
<div ref={messagesEndRef} className="h-1" />
</div>
{/* Manual scroll button that only appears when not at the bottom */}
{!autoScroll && filteredMessages.length > 6 && (
<div className="sticky bottom-5 ml-auto mr-5 z-10 w-fit">
<Button
size="sm"
className="rounded-full shadow-md h-10 w-10 p-0"
onClick={scrollToBottom}
title="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</ScrollArea>
</div>
{/* AI Reasoning Panel - pinned above controls */}
<ReasoningPanel
reasoningHistory={reasoningHistory}
isVisible={reasoningPanelVisible}
onToggle={() => setReasoningPanelExpanded(!reasoningPanelExpanded)}
isAiMode={isAiModeActive}
/>
{/* Control panel - pinned to bottom */}
<div className="pt-4 border-t border-slate-200 w-full">
{/* Show selected file indicator */}
{selectedFile && (
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded-md flex items-center justify-between">
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-blue-600" />
<span className="text-sm text-blue-700">{selectedFile.name}</span>
<span className="text-xs text-blue-500">({(selectedFile.size / 1024 / 1024).toFixed(1)} MB)</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearSelectedFile}
className="h-6 w-6 p-0 text-blue-600 hover:text-blue-800"
>
×
</Button>
</div>
)}
<form onSubmit={handleSubmit} className="flex items-center gap-2 w-full">
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<MentionInput
value={userInput}
onChange={(value, mentions) => {
setUserInput(value);
setCurrentMentions(mentions || null);
}}
participants={personas}
placeholder="Ask a question or provide guidance..."
className="flex-1 min-w-0"
disabled={false}
/>
{/* Attachment button */}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="hover-transition shrink-0 px-3"
disabled={false}
title="Attach image for creative review"
>
<Paperclip className="h-4 w-4" />
</Button>
<Button
type="submit"
variant="default"
className="hover-transition shrink-0"
disabled={false}
>
<MessageSquare className="mr-2 h-4 w-4" />
Send
</Button>
</form>
<div className="flex justify-between items-center mt-3">
<div className="flex items-center space-x-2">
<p className="text-sm text-slate-500">
{isSpeaking ? 'Speaking...' :
isAiModeActive ? 'AI mode active' :
'Manual moderation mode'}
</p>
{/* Autonomous Mode Toggle */}
<Button
variant="outline"
size="sm"
onClick={effectiveAiModeActive ? stopAutonomousConversation : startAutonomousConversation}
disabled={autonomousLoading}
className={`hover-transition ${
effectiveAiModeActive
? 'bg-red-50 text-red-600 hover:bg-red-100'
: 'bg-blue-50 text-blue-600 hover:bg-blue-100'
}`}
title={effectiveAiModeActive ? "Stop AI mode and return to manual" : "Start autonomous AI conversation"}
>
{autonomousLoading ? (
<>
<Bot className="mr-1 h-3 w-3 animate-spin" />
{isAiModeActive ? 'Stopping...' : 'Starting...'}
</>
) : effectiveAiModeActive ? (
<>
<Bot className="mr-1 h-3 w-3" />
Stop AI Mode
</>
) : (
<>
<Bot className="mr-1 h-3 w-3" />
Start AI Mode
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setAutoScroll(!autoScroll);
// If enabling, immediately scroll to bottom
if (!autoScroll) {
scrollToBottom();
}
}}
className={`hover-transition ${autoScroll ? 'bg-blue-50 text-blue-600 hover:bg-blue-100' : ''}`}
title={autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll'}
>
<ArrowDown className="h-3 w-3 mr-1" />
Auto-scroll
</Button>
</div>
<div className="flex items-center gap-2">
{/* Show manual controls only when not in AI mode */}
{!isAiModeActive && (
<>
<Button
variant="outline"
onClick={advanceDiscussion}
className={`hover-transition ${personas.length === 0 ? 'bg-red-50' : ''}`}
disabled={isTyping}
title={personas.length === 0 ? "Add participants to the focus group first" : "Advance to the next part of the discussion guide"}
>
<MessageSquare className="mr-2 h-4 w-4" />
{personas.length === 0 ? "No Participants" : "Advance Discussion"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={generateAIResponse}
className={`hover-transition ${personas.length === 0 ? 'bg-red-50' : ''}`}
disabled={isTyping || personas.length === 0}
title="Generate a participant response to the current topic"
>
<MessageCircle className="mr-1 h-3 w-3" />
Get Response
</Button>
</>
)}
{/* Show AI mode status and controls */}
{isAiModeActive && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-slate-600">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span>AI Active</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowAutonomousControls(!showAutonomousControls)}
className="hover-transition"
title="Show autonomous conversation controls"
>
<Settings className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default DiscussionPanel;