1395 lines
50 KiB
TypeScript
1395 lines
50 KiB
TypeScript
|
||
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;
|