import { useState, useEffect, useCallback } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigation } from '@/contexts/NavigationContext'; import Navigation from '@/components/Navigation'; import AIRecruiter from '@/components/AIRecruiter'; import UserCreator from '@/components/UserCreator'; import UserCard from '@/components/UserCard'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Search, Filter, Users, FolderPlus, Folder, MoreHorizontal, Plus, Check, X, Trash2, Download, MessageSquare } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Persona } from '@/types/persona'; import { usePersonaStorage } from '@/hooks/usePersonaStorage'; import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; import { getSocket } from '@/services/websocketServiceNew'; import { personasApi, aiPersonasApi, foldersApi } from '@/lib/api'; import { toastService } from '@/lib/toast'; import ProgressModal from '@/components/ui/ProgressModal'; import FolderTree from '@/components/FolderTree'; interface Folder { _id: string; id?: string; // Legacy field for compatibility name: string; parent_folder_id?: string | null; level: number; created_by?: string; created_at?: string; updated_at?: string; // Note: No longer storing persona_ids - using persona-centric storage } const DEFAULT_FOLDER_ID = 'all'; // Define filter state interface interface FilterState { age: string[]; gender: string[]; occupation: string[]; location: string[]; techSavviness: string[]; ethnicity: string[]; folderStatus: string[]; } const SyntheticUsers = () => { // Helper function ONLY to ensure the body is interactive - memoized with useCallback const ensureBodyInteractive = useCallback(() => { if (document.body.style.pointerEvents === 'none') { console.log('ensureBodyInteractive: Fixing body pointer-events...'); // Optional log document.body.style.pointerEvents = 'auto'; } }, []); // Empty dependency array because it has no external dependencies const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { loadPersonas } = usePersonaStorage(); const { clearNavigationState, setPreviousRoute } = useNavigation(); const [mode, setMode] = useState<'view' | 'create'>('view'); const [creationMode, setCreationMode] = useState<'manual' | 'ai'>('ai'); // WebSocket and cancellable generation for summary generation const socket = getSocket(); const [summaryGenerationState, summaryGenerationControls] = useCancellableGeneration('persona summary generation', socket); const [isSummaryProgressModalOpen, setIsSummaryProgressModalOpen] = useState(false); const [summaryProgressDescription, setSummaryProgressDescription] = useState(''); // Bulk export no longer needs cancellable generation - it's instant const [searchTerm, setSearchTerm] = useState(''); const [selectedUser, setSelectedUser] = useState(null); const [selectedFolder, setSelectedFolder] = useState(DEFAULT_FOLDER_ID); // Handle URL parameters to set mode and folder useEffect(() => { const modeParam = searchParams.get('mode'); if (modeParam === 'view' || modeParam === 'create') { setMode(modeParam); } // Handle folder parameter to restore folder selection const folderParam = searchParams.get('folder'); if (folderParam) { setSelectedFolder(folderParam); } }, [searchParams]); const [allPersonas, setAllPersonas] = useState([]); const [folders, setFolders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [selectedPersonas, setSelectedPersonas] = useState>(new Set()); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteFolderConfirmOpen, setDeleteFolderConfirmOpen] = useState(false); const [folderToDelete, setFolderToDelete] = useState(null); const [moveToFolderOpen, setMoveToFolderOpen] = useState(false); const [targetFolders, setTargetFolders] = useState>(new Set()); // Filter state const [isFilterOpen, setIsFilterOpen] = useState(false); const [activeFilters, setActiveFilters] = useState({ age: [], gender: [], occupation: [], location: [], techSavviness: [], ethnicity: [], folderStatus: [], }); // Working copy of filter state for the dialog const [workingFilters, setWorkingFilters] = useState({ age: [], gender: [], occupation: [], location: [], techSavviness: [], ethnicity: [], folderStatus: [], }); // LLM selection for download const [downloadLlmModalOpen, setDownloadLlmModalOpen] = useState(false); const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState('gemini-3-pro-preview'); // Bulk export no longer needs state - direct download // Handle summary generation progress completion const handleSummaryProgressComplete = () => { setIsSummaryProgressModalOpen(false); summaryGenerationControls.resetGeneration(); }; // Handle navigation to persona details from synthetic users list const handlePersonaClick = (persona: Persona) => { // Clear any existing navigation state since we're coming from synthetic users clearNavigationState(); // Navigate to persona details navigate(`/synthetic-users/${persona._id || persona.id}`); }; // Handle navigation to persona details with folder context const handlePersonaViewDetails = (persona: Persona) => { // Set navigation context to remember which folder we came from setPreviousRoute('/synthetic-users', { folderId: selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : undefined }); // Navigate to persona details navigate(`/synthetic-users/${persona._id || persona.id}`); }; // Function to collect unique filter options from personas const getFilterOptions = (personas: Persona[]) => { const options = { age: new Set(), gender: new Set(), occupation: new Set(), location: new Set(), techSavviness: new Set(), ethnicity: new Set(), }; personas.forEach(persona => { // Add each attribute to the corresponding set if (persona.age) options.age.add(persona.age); if (persona.gender) options.gender.add(persona.gender); if (persona.occupation) options.occupation.add(persona.occupation); if (persona.location) options.location.add(persona.location); // Map numeric ranges to descriptive labels if (persona.techSavviness !== undefined) { const techLevel = persona.techSavviness < 30 ? 'Low (0-30)' : persona.techSavviness < 70 ? 'Medium (31-70)' : 'High (71-100)'; options.techSavviness.add(techLevel); } // Convert boolean values to string labels if (persona.ethnicity) options.ethnicity.add(persona.ethnicity); }); // Convert sets to sorted arrays return { age: Array.from(options.age).sort(), gender: Array.from(options.gender).sort(), occupation: Array.from(options.occupation).sort(), location: Array.from(options.location).sort(), techSavviness: Array.from(options.techSavviness).sort((a, b) => { const order = ['Low (0-30)', 'Medium (31-70)', 'High (71-100)']; return order.indexOf(a) - order.indexOf(b); }), ethnicity: Array.from(options.ethnicity).sort(), }; }; // Reset filters function const resetFilters = () => { setActiveFilters({ age: [], gender: [], occupation: [], location: [], techSavviness: [], ethnicity: [], folderStatus: [], }); }; // Apply filters function const applyFilters = () => { // First close the dialog setIsFilterOpen(false); // Then apply the filters after stack clear setTimeout(() => { setActiveFilters({...workingFilters}); }, 0); }; // Reset filters const handleResetFilters = () => { setWorkingFilters({ age: [], gender: [], occupation: [], location: [], techSavviness: [], ethnicity: [], folderStatus: [], }); }; // Toggle a filter value const toggleFilter = (category: keyof FilterState, value: string) => { setWorkingFilters(prev => { const newFilters = {...prev}; // If value is already selected, remove it if (newFilters[category].includes(value)) { newFilters[category] = newFilters[category].filter(v => v !== value); } else { // Otherwise add it newFilters[category] = [...newFilters[category], value]; } return newFilters; }); }; // Function to fetch folders from API const fetchFolders = async () => { try { const response = await foldersApi.getAll(); const serverFolders = response.data; // Convert server folder format to match frontend expectations const processedFolders: Folder[] = serverFolders.map((folder: any) => ({ ...folder, id: folder._id // Add legacy id field for compatibility // Note: No longer processing persona_ids - using persona-centric storage })); setFolders(processedFolders); return processedFolders; } catch (error) { console.error("Error fetching folders:", error); toastService.error("Failed to load folders"); setFolders([]); return []; } }; // Extract fetchPersonas into a separate function to be used in multiple places const fetchPersonas = async () => { const isMounted = true; setIsLoading(true); try { // Fetch personas from API using the proper API client const response = await personasApi.getAll(); // API client already handles response parsing const data = response.data; if (isMounted) { // Process API data to ensure consistent format const processedData = data.map((p: Persona) => ({ ...p, // Ensure id is set for compatibility id: p.id || p._id, })); // We're now only showing personas from the database, no local storage const combinedPersonas = [...processedData]; // Load stored personas asynchronously only for data migration purposes // but we won't show them to the user anymore try { const loadStoredPersonas = async () => { const storedPersonas = await loadPersonas(); console.log('Loaded stored personas (for debugging only):', storedPersonas ? storedPersonas.length : 0); }; loadStoredPersonas(); } catch (e) { console.warn('Error loading stored personas:', e); } // Not loading local personas anymore setAllPersonas(combinedPersonas); } } catch (error) { console.error("Error fetching personas:", error); if (isMounted) { toastService.error("Failed to load personas"); setAllPersonas([]); } } finally { if (isMounted) { setIsLoading(false); } } }; // Note: Folder synchronization is now handled server-side // Initial load effect useEffect(() => { // Use a flag to track if component is still mounted let isMounted = true; // Fetch both folders and personas from API const fetchData = async () => { try { // Fetch folders and personas in parallel const [, ] = await Promise.all([ fetchFolders(), fetchPersonas() ]); } catch (error) { console.error("Error loading data:", error); } }; fetchData(); // Cleanup function to prevent memory leaks return () => { isMounted = false; }; }, [ensureBodyInteractive]); // Add ensureBodyInteractive dependency // Add an effect to refresh data when 'view' mode is activated // This ensures personas are refreshed when navigating back from creation useEffect(() => { if (mode === 'view') { fetchPersonas(); } else if (mode === 'create') { // Log selected folder when switching to create mode console.log(`Switching to create mode with folder: ${selectedFolder}, ${selectedFolder !== DEFAULT_FOLDER_ID ? 'NOT default' : 'IS default'}`); if (selectedFolder !== DEFAULT_FOLDER_ID) { const folderName = folders.find(f => f.id === selectedFolder)?.name; console.log(`Selected folder for creation: ${selectedFolder} (${folderName})`); } } }, [mode]); // Note: Folder-persona relationships are now managed server-side // Listen for navigation events to ensure data is refreshed when navigating to this page useEffect(() => { // This will run when the component mounts (user navigates to the page) fetchPersonas(); // Create an event listener for route changes within the app const handleRouteChange = () => { // Check if we're on the synthetic users page if (window.location.pathname.includes('/synthetic-users') && !window.location.pathname.includes('/synthetic-users/')) { console.log('Navigation to synthetic users page detected, refreshing data'); fetchPersonas(); } }; // Listen for the custom event from the Navigation component const handleSyntheticUsersNavigation = () => { console.log('Synthetic users navigation event detected, refreshing data'); fetchPersonas(); }; // --- ADD MutationObserver LOGIC --- console.log('Setting up MutationObserver for body style'); const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if ( mutation.type === 'attributes' && mutation.attributeName === 'style' && document.body.style.pointerEvents === 'none' ) { // Use the memoized function ensureBodyInteractive console.log('MutationObserver detected pointer-events: none, fixing...'); ensureBodyInteractive(); } }); }); observer.observe(document.body, { attributes: true, attributeFilter: ['style'], }); // --- END ADD MutationObserver LOGIC --- // Ensure body is interactive when component mounts initially ensureBodyInteractive(); // Add event listeners window.addEventListener('popstate', handleRouteChange); window.addEventListener('syntheticUsersNavigation', handleSyntheticUsersNavigation); // Cleanup function return () => { window.removeEventListener('popstate', handleRouteChange); window.removeEventListener('syntheticUsersNavigation', handleSyntheticUsersNavigation); // --- ADD Observer Cleanup --- console.log('Disconnecting MutationObserver'); observer.disconnect(); // --- END ADD Observer Cleanup --- }; }, []); // Note: Folders are now stored server-side, no localStorage needed // Note: Server-side folder management eliminates need for manual synchronization const createNewFolder = async (name: string, parentId?: string) => { if (!name.trim()) { toastService.error("Please enter a folder name"); return; } try { const folderData: any = { name: name.trim(), persona_ids: [] }; if (parentId) { folderData.parent_folder_id = parentId; } const response = await foldersApi.create(folderData); // Refresh folders from server await fetchFolders(); const folderType = parentId ? 'Sub-folder' : 'Folder'; toastService.success(`${folderType} "${name}" created`); } catch (error) { console.error("Error creating folder:", error); const errorMessage = error.response?.data?.message || "Failed to create folder"; toastService.error(errorMessage); } }; const renameFolder = async (folderId: string, newName: string) => { try { await foldersApi.update(folderId, { name: newName }); // Refresh folders from server await fetchFolders(); toastService.success(`Folder renamed to "${newName}"`); } catch (error) { console.error("Error renaming folder:", error); const errorMessage = error.response?.data?.message || "Failed to rename folder"; toastService.error(errorMessage); } }; const moveFolder = async (folderId: string, newParentId: string | null) => { try { await foldersApi.moveFolder(folderId, newParentId); // Refresh folders from server await fetchFolders(); const targetName = newParentId ? folders.find(f => f._id === newParentId)?.name || 'folder' : 'root level'; toastService.success(`Folder moved to ${targetName}`); } catch (error) { console.error("Error moving folder:", error); const errorMessage = error.response?.data?.message || "Failed to move folder"; toastService.error(errorMessage); } }; const startDeleteFolder = (folder: Folder) => { setFolderToDelete(folder); setDeleteFolderConfirmOpen(true); }; const completeDeleteFolder = async () => { if (!folderToDelete) return; try { await foldersApi.delete(folderToDelete._id); // Refresh folders from server await fetchFolders(); // Update selected folder if the deleted folder was selected if (selectedFolder === folderToDelete._id || selectedFolder === folderToDelete.id) { setSelectedFolder(DEFAULT_FOLDER_ID); } setDeleteFolderConfirmOpen(false); setFolderToDelete(null); toastService.success(`Folder "${folderToDelete.name}" deleted`); } catch (error) { console.error("Error deleting folder:", error); toastService.error("Failed to delete folder"); } }; const movePersonasToFolder = async (personasToMove?: Set, targetFolderIds?: Set) => { // Support both direct calls and calls from the dialog button const personas = personasToMove || selectedPersonas; const folderIds = targetFolderIds || targetFolders; if (folderIds.size === 0 || personas.size === 0) return; const personaIds = Array.from(personas); // Convert persona.id to persona._id (MongoDB IDs) for backend operations const mongoIds = personaIds.map(personaId => { const persona = allPersonas.find(p => p.id === personaId); return persona?._id || persona?.id || personaId; }).filter(Boolean); try { const successfulUpdates: string[] = []; const failedUpdates: string[] = []; const folderNames: string[] = []; // Handle "All Personas" selection (remove from all folders) if (folderIds.has(DEFAULT_FOLDER_ID)) { // Remove personas from all current folders const currentFolderIds = new Set(); personaIds.forEach(personaId => { const persona = allPersonas.find(p => p.id === personaId); if (persona?.folder_ids) { persona.folder_ids.forEach(fid => currentFolderIds.add(fid)); } }); for (const folderId of currentFolderIds) { try { await foldersApi.removePersonasBatch(folderId, mongoIds); } catch (error) { console.error("Error removing personas from folder:", error); } } successfulUpdates.push(...personaIds); folderNames.push("All Personas (removed from folders)"); } else { // Add personas to multiple selected folders for (const folderId of folderIds) { try { await foldersApi.addPersonasBatch(folderId, mongoIds); successfulUpdates.push(...personaIds); const folderName = folders.find(f => f._id === folderId || f.id === folderId)?.name || "folder"; folderNames.push(folderName); } catch (error) { console.error(`Error adding personas to folder ${folderId}:`, error); failedUpdates.push(...personaIds); } } } // Refresh data from server await Promise.all([fetchFolders(), fetchPersonas()]); // Show toast messages if (successfulUpdates.length > 0) { const folderList = folderNames.length > 1 ? folderNames.slice(0, -1).join(', ') + ' and ' + folderNames.slice(-1) : folderNames[0]; toastService.success(`Added ${successfulUpdates.length} persona${successfulUpdates.length !== 1 ? 's' : ''} to ${folderList}`); } if (failedUpdates.length > 0) { toastService.error(`Failed to add some personas to selected folders.`); } // Clear selection - caller can also handle this if needed if (!personasToMove) { setSelectedPersonas(new Set()); } return { success: successfulUpdates.length > 0, successCount: successfulUpdates.length, failureCount: failedUpdates.length }; } catch (error) { console.error("Error moving personas to folder:", error); toastService.error("An unexpected error occurred while adding personas to folder."); return { success: false, error }; } }; const addPersonaToFolder = async (personaId: string, folderId: string) => { // First, update local folder state const updatedFolders = folders.map(folder => { // Add to target folder if not already included if (folder.id === folderId) { if (!folder.personaIds.includes(personaId)) { return { ...folder, personaIds: [...folder.personaIds, personaId] }; } } // Remove from other folders else { return { ...folder, personaIds: folder.personaIds.filter(id => id !== personaId) }; } return folder; }); setFolders(updatedFolders); // Then, update the persona in the database with the new folder try { const persona = allPersonas.find(p => p.id === personaId); if (persona) { // Prepare updated persona with folder information const updatedPersona = { ...persona, folderId: folderId }; // Find the right ID to use const idToUse = persona._id || persona.id; // Update locally first for immediate feedback setAllPersonas(prev => prev.map(p => p.id === personaId ? { ...p, folderId: folderId } : p )); // Call API to update the persona await personasApi.update(idToUse, updatedPersona); // Add small delay to prevent UI interaction issues setTimeout(() => { toastService.success("Persona added to folder"); }, 100); } } catch (error) { console.error("Failed to update persona folder:", error); setTimeout(() => { toastService.error("Failed to update persona folder"); }, 100); } }; const removeSelectedPersonasFromCurrentFolder = async () => { if (selectedPersonas.size === 0 || selectedFolder === DEFAULT_FOLDER_ID) return; const selectedIds = Array.from(selectedPersonas); // Convert persona.id to persona._id (MongoDB IDs) for backend operations const mongoIds = selectedIds.map(personaId => { const persona = allPersonas.find(p => p.id === personaId); return persona?._id || persona?.id || personaId; }).filter(Boolean); console.log('Removing personas from folder:', { selectedFolder, selectedIds, mongoIds, folderName: folders.find(f => f._id === selectedFolder)?.name }); try { // Remove personas from the current folder using the batch API await foldersApi.removePersonasBatch(selectedFolder, mongoIds); // Refresh data from server await Promise.all([fetchFolders(), fetchPersonas()]); const folderName = folders.find(f => f._id === selectedFolder)?.name || 'folder'; toastService.success(`Removed ${selectedIds.length} persona${selectedIds.length !== 1 ? 's' : ''} from ${folderName}`); // Clear selection setSelectedPersonas(new Set()); } catch (error) { console.error("Error removing personas from folder:", error); console.error("Error details:", error.response?.data || error.message); toastService.error("Failed to remove personas from folder"); } }; const togglePersonaSelection = (personaId: string) => { setSelectedPersonas(prevSelected => { const newSelected = new Set(prevSelected); if (newSelected.has(personaId)) { newSelected.delete(personaId); } else { newSelected.add(personaId); } return newSelected; }); }; const handleSelectAllPersonas = () => { if (selectedPersonas.size === filteredPersonas.length) { // Deselect all if all are currently selected setSelectedPersonas(new Set()); } else { // Select all filtered personas setSelectedPersonas(new Set(filteredPersonas.map(p => p.id))); } }; const deleteSelectedPersonas = async () => { if (selectedPersonas.size === 0) return; // Capture selected persona IDs before any state changes const selectedIds = Array.from(selectedPersonas); // Clear the selection and close dialog immediately setSelectedPersonas(new Set()); // Clear selection first setDeleteConfirmOpen(false); // Close modal second setIsLoading(true); // Show loading state last const successfulDeletes: string[] = []; const failedDeletes: string[] = []; // Process personas one by one to better handle errors for (const id of selectedIds) { try { // Find the complete persona object to get the MongoDB _id const persona = allPersonas.find(p => p.id === id); if (!persona) { console.error(`Could not find persona with id: ${id}`); failedDeletes.push(id); continue; } // Use the appropriate ID for the API call // The backend expects a MongoDB ObjectId string let idToUse = id; if (persona._id) { idToUse = persona._id.toString(); } console.log(`Attempting to delete persona: ${idToUse}`); await personasApi.delete(idToUse); successfulDeletes.push(id); } catch (error) { console.error(`Failed to delete persona ${id}:`, error); failedDeletes.push(id); // Don't show toast for each failure to avoid spamming the user } } // Update the personas list to remove successfully deleted personas setAllPersonas(prev => prev.filter(persona => !successfulDeletes.includes(persona.id))); // Refresh folders from server to reflect persona deletions await fetchFolders(); setIsLoading(false); // Show success/failure messages with a small delay setTimeout(() => { if (successfulDeletes.length > 0) { toastService.success(`Successfully deleted ${successfulDeletes.length} persona${successfulDeletes.length !== 1 ? 's' : ''}`); } if (failedDeletes.length > 0) { toastService.error(`Failed to delete ${failedDeletes.length} persona${failedDeletes.length !== 1 ? 's' : ''}`); } // Refresh the personas list to ensure consistency if (successfulDeletes.length > 0 || failedDeletes.length > 0) { fetchPersonas(); } }, 50); }; const filteredPersonas = allPersonas.filter(persona => { // Text search matching const matchesSearch = ( persona.name.toLowerCase().includes(searchTerm.toLowerCase()) || persona.occupation.toLowerCase().includes(searchTerm.toLowerCase()) || persona.location.toLowerCase().includes(searchTerm.toLowerCase()) ); // Apply additional filter criteria const matchesFilters = // Match age filter (activeFilters.age.length === 0 || activeFilters.age.includes(persona.age)) && // Match gender filter (activeFilters.gender.length === 0 || activeFilters.gender.includes(persona.gender)) && // Match occupation filter (activeFilters.occupation.length === 0 || activeFilters.occupation.includes(persona.occupation)) && // Match location filter (activeFilters.location.length === 0 || activeFilters.location.includes(persona.location)) && // Match ethnicity filter (activeFilters.ethnicity.length === 0 || (persona.ethnicity && activeFilters.ethnicity.includes(persona.ethnicity))) && // Match tech savviness filter (convert numeric value to text bucket) (activeFilters.techSavviness.length === 0 || (persona.techSavviness !== undefined && activeFilters.techSavviness.includes( persona.techSavviness < 30 ? 'Low (0-30)' : persona.techSavviness < 70 ? 'Medium (31-70)' : 'High (71-100)' ))) && // Match folder status filter (activeFilters.folderStatus.length === 0 || // If both options selected, show all personas (OR logic) (activeFilters.folderStatus.includes('hasFolder') && activeFilters.folderStatus.includes('noFolder')) || // If only "hasFolder" selected (activeFilters.folderStatus.includes('hasFolder') && !activeFilters.folderStatus.includes('noFolder') && ((persona.folder_ids && persona.folder_ids.length > 0) || (persona.folder_id && persona.folder_id !== DEFAULT_FOLDER_ID) || (persona.folderId && persona.folderId !== DEFAULT_FOLDER_ID))) || // If only "noFolder" selected (activeFilters.folderStatus.includes('noFolder') && !activeFilters.folderStatus.includes('hasFolder') && ((!persona.folder_ids || persona.folder_ids.length === 0) && (!persona.folder_id || persona.folder_id === DEFAULT_FOLDER_ID) && (!persona.folderId || persona.folderId === DEFAULT_FOLDER_ID))) ); // First check if the selected folder is "All Personas" if (selectedFolder === DEFAULT_FOLDER_ID) { return matchesSearch && matchesFilters; } // Check if the persona belongs to the selected folder (persona-centric storage) // Check if the persona has this folder in its folder_ids array if (persona.folder_ids && Array.isArray(persona.folder_ids)) { const isInFolder = persona.folder_ids.includes(selectedFolder); if (isInFolder) { return matchesSearch && matchesFilters; } } // Legacy support: Check using the single folder_id property if (persona.folder_id === selectedFolder) { return matchesSearch && matchesFilters; } // Also check legacy folderId for backwards compatibility if (persona.folderId === selectedFolder) { return matchesSearch && matchesFilters; } // No folder membership found return false; }); // Generate markdown content for persona summary export const generatePersonaSummaryMarkdown = (personas: Persona[], folderName: string) => { const currentDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format const personaCount = personas.length; let markdown = `# Persona Summary Report\n\n`; markdown += `**Folder:** ${folderName}\n`; markdown += `**Date:** ${currentDate}\n`; markdown += `**Total Personas:** ${personaCount}\n\n`; if (personaCount === 0) { markdown += `No personas found in this folder.\n`; return markdown; } personas.forEach((persona, index) => { // Persona header markdown += `## ${persona.name}\n\n`; // Demographics markdown += `### Demographics\n`; markdown += `- **Age:** ${persona.age}\n`; markdown += `- **Gender:** ${persona.gender}\n`; markdown += `- **Occupation:** ${persona.occupation}\n`; markdown += `- **Location:** ${persona.location}\n\n`; // AI-Synthesized Bio if (persona.aiSynthesizedBio) { markdown += `### AI-Synthesized Bio\n`; markdown += `${persona.aiSynthesizedBio}\n\n`; } // Qualitative Attributes if (persona.qualitativeAttributes && persona.qualitativeAttributes.length > 0) { markdown += `### Key Attributes\n`; persona.qualitativeAttributes.forEach(attribute => { markdown += `- 🏷️ ${attribute}\n`; }); markdown += `\n`; } // Top Personality Traits if (persona.topPersonalityTraits && persona.topPersonalityTraits.length > 0) { markdown += `### Top Personality Traits\n`; persona.topPersonalityTraits.forEach(trait => { markdown += `- 🧠 ${trait}\n`; }); markdown += `\n`; } // Add horizontal rule between personas (except for the last one) if (index < personas.length - 1) { markdown += `---\n\n`; } }); return markdown; }; // Download persona summary for current folder const downloadPersonaSummary = async () => { if (filteredPersonas.length === 0) { toastService.error("No personas to download"); return; } // Show LLM selection modal immediately setDownloadLlmModalOpen(true); }; // Handle the actual download with selected model const handleDownloadWithModel = async () => { const folderName = selectedFolder === DEFAULT_FOLDER_ID ? 'All Personas' : folders.find(f => f.id === selectedFolder)?.name || 'Unknown Folder'; // Extract persona IDs, using _id for database personas or id as fallback const personaIds = filteredPersonas.map(persona => persona._id || persona.id); // Log user's model selection console.log(`🤖 Frontend: User selected ${selectedDownloadLlmModel} for persona summary download`); // Close modal setDownloadLlmModalOpen(false); // Start cancellable generation and open progress modal summaryGenerationControls.startGeneration(); setSummaryProgressDescription(`Generating AI-powered summaries for ${filteredPersonas.length} persona${filteredPersonas.length !== 1 ? 's' : ''}. This may take a few minutes.`); setIsSummaryProgressModalOpen(true); setIsLoading(true); try { // Show initial toast with progress toastService.info("Generating persona summaries...", { description: `Processing ${filteredPersonas.length} persona${filteredPersonas.length !== 1 ? 's' : ''} with AI` }); // Call the new API endpoint for batch summary generation const response = await aiPersonasApi.batchGenerateSummaries(personaIds, 0.7, selectedDownloadLlmModel); const { summaries, summary_stats, errors, task_id } = response.data; // Set task ID for cancellation if (task_id) { summaryGenerationControls.setTaskId(task_id); } // Generate markdown content from LLM-processed summaries const currentDate = new Date().toISOString().split('T')[0]; const fileName = `persona-summary-${folderName.toLowerCase().replace(/\s+/g, '-')}-${currentDate}.md`; let markdownContent = `# Persona Summary Report\n\n`; markdownContent += `**Folder:** ${folderName}\n`; markdownContent += `**Date:** ${currentDate}\n`; markdownContent += `**Total Personas:** ${summary_stats.total_requested}\n`; markdownContent += `**Successfully Processed:** ${summary_stats.total_successful}\n`; if (summary_stats.total_failed > 0) { markdownContent += `**Failed to Process:** ${summary_stats.total_failed}\n`; } markdownContent += `\n---\n\n`; if (summaries.length === 0) { markdownContent += `No persona summaries could be generated.\n`; } else { // Add each persona summary summaries.forEach((summaryData, index) => { markdownContent += `# ${summaryData.persona_name}\n\n`; markdownContent += `${summaryData.summary}\n\n`; // Add separator between personas (except for the last one) if (index < summaries.length - 1) { markdownContent += `---\n\n`; } }); } // Add error section if there were failures if (errors && (errors.failed_summaries?.length > 0 || errors.missing_personas?.length > 0)) { markdownContent += `\n---\n\n## Processing Errors\n\n`; if (errors.failed_summaries?.length > 0) { markdownContent += `### Failed to Generate Summaries\n`; errors.failed_summaries.forEach(failure => { markdownContent += `- **${failure.persona_name}** (ID: ${failure.persona_id}): ${failure.error}\n`; }); markdownContent += `\n`; } if (errors.missing_personas?.length > 0) { markdownContent += `### Missing Personas\n`; errors.missing_personas.forEach(id => { markdownContent += `- ID: ${id}\n`; }); } } // Create and download the file const element = document.createElement('a'); const file = new Blob([markdownContent], { type: 'text/markdown' }); element.href = URL.createObjectURL(file); element.download = fileName; document.body.appendChild(element); element.click(); document.body.removeChild(element); // Mark generation as complete summaryGenerationControls.completeGeneration(); // Show success toast with details including model information const modelDisplayName = selectedDownloadLlmModel === 'gpt-4.1' ? 'GPT-4.1' : 'Gemini 3 Pro'; if (summary_stats.total_successful === summary_stats.total_requested) { toastService.success("Persona summary downloaded", { description: `Successfully processed all ${summary_stats.total_successful} persona${summary_stats.total_successful !== 1 ? 's' : ''} from "${folderName}" using ${modelDisplayName}` }); } else { toastService.success("Persona summary downloaded with warnings", { description: `Processed ${summary_stats.total_successful} of ${summary_stats.total_requested} personas from "${folderName}" using ${modelDisplayName}` }); } } catch (error) { // Check if this was a cancellation (expected behavior) if (error.response?.status === 499) { return; } console.error("Error generating persona summaries:", error); // Mark generation as failed summaryGenerationControls.failGeneration(error.message || 'Failed to generate summaries'); // Fall back to basic summary if API fails toastService.error("AI summary generation failed, creating basic summary", { description: "Using simplified format due to processing error" }); try { const currentDate = new Date().toISOString().split('T')[0]; const fileName = `persona-summary-basic-${folderName.toLowerCase().replace(/\s+/g, '-')}-${currentDate}.md`; const basicContent = generatePersonaSummaryMarkdown(filteredPersonas, folderName); const element = document.createElement('a'); const file = new Blob([basicContent], { type: 'text/markdown' }); element.href = URL.createObjectURL(file); element.download = fileName; document.body.appendChild(element); element.click(); document.body.removeChild(element); } catch (fallbackError) { toastService.error("Failed to create persona summary", { description: "Unable to generate summary in any format" }); } } finally { setIsLoading(false); } }; // Bulk export functions const handleBulkExport = async (format: 'markdown' | 'json' | 'csv') => { const selectedIds = Array.from(selectedPersonas); if (selectedIds.length === 0) { toastService.error("Please select personas to export"); return; } // Show loading toast toastService.info(`Exporting ${selectedIds.length} persona${selectedIds.length !== 1 ? 's' : ''} to ${format.toUpperCase()}...`); try { // Get JWT token for the request const token = localStorage.getItem('auth_token'); const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/semblance_back/api'; // Make direct fetch request since response will be a file const response = await fetch(`${API_BASE_URL}/personas/bulk-export`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ persona_ids: selectedIds, export_format: format }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Export failed'); } // The response should be the file blob directly const blob = await response.blob(); // Create download link const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `persona_profiles_${format}_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.zip`; document.body.appendChild(link); link.click(); document.body.removeChild(link); // Clean up URL.revokeObjectURL(url); toastService.success(`${format.toUpperCase()} export completed!`, { description: `Successfully exported ${selectedIds.length} persona profile${selectedIds.length !== 1 ? 's' : ''}` }); } catch (error) { console.error("Error during bulk export:", error); toastService.error("Export failed", { description: error.message || 'Unknown error occurred' }); } }; // Removed separate download function - now using direct file response return (

Synthetic Personas

Create and manage AI-generated research participants

{mode === 'view' && filteredPersonas.length > 0 && ( )}
{/* Progress Modal for Persona Summary Generation */} setIsSummaryProgressModalOpen(false)} isActive={summaryGenerationState.isGenerating} isComplete={summaryGenerationState.isComplete} hasError={summaryGenerationState.hasError} isCancelling={summaryGenerationState.isCancelling} taskId={summaryGenerationState.taskId} title="Generating Persona Summaries" description={summaryProgressDescription} onCancel={summaryGenerationControls.cancelGeneration} onComplete={handleSummaryProgressComplete} /> {mode === 'view' ? ( <>
setSearchTerm(e.target.value)} />
{selectedPersonas.size > 0 && ( { // Prevent focusing after close which can cause issues e.preventDefault(); }} > { e.preventDefault(); e.stopPropagation(); const selectedIds = Array.from(selectedPersonas); navigate('/focus-groups', { state: { mode: 'create', preSelectedParticipants: selectedIds } }); }} > Create Focus Group with selected Personas { e.preventDefault(); e.stopPropagation(); setDeleteConfirmOpen(true); }} > Delete { e.preventDefault(); e.stopPropagation(); setMoveToFolderOpen(true); }} > Move to folder {selectedFolder !== DEFAULT_FOLDER_ID && ( { e.preventDefault(); e.stopPropagation(); removeSelectedPersonasFromCurrentFolder(); }} > Remove from {folders.find(f => f._id === selectedFolder)?.name || 'folder'} )} { e.preventDefault(); e.stopPropagation(); handleBulkExport('markdown'); }} > Download Full Persona Profiles (Markdown) { e.preventDefault(); e.stopPropagation(); handleBulkExport('json'); }} > Download Full Persona Profiles (JSON) { e.preventDefault(); e.stopPropagation(); handleBulkExport('csv'); }} > Download Full Persona Profiles (CSV) )}

{selectedFolder === DEFAULT_FOLDER_ID ? 'Your Synthetic Persona Library' : folders.find(f => f._id === selectedFolder)?.name || 'Personas'}

({filteredPersonas.length})
{filteredPersonas.length > 0 && (
0 && selectedPersonas.size === filteredPersonas.length} onCheckedChange={handleSelectAllPersonas} className="mr-2" />
)}
{filteredPersonas.length > 0 ? (
{filteredPersonas.map((persona) => (
handlePersonaClick(persona)} onSelectionToggle={(e) => { e.stopPropagation(); togglePersonaSelection(persona.id); }} onViewDetails={handlePersonaViewDetails} showAddToFolderButton={false} folders={folders} />
))}
) : (

No personas found matching your criteria.

)}
{ // If dialog is closing without delete action if (!open) { setDeleteConfirmOpen(false); } else { setDeleteConfirmOpen(open); } }} > { // Prevent interaction with outside elements but do not block pointer events // This prevents clicks from going through but won't disable the whole body e.preventDefault(); }} > Delete Personas Are you sure you want to delete {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''}? This action cannot be undone. { // Clear selection after canceling setTimeout(() => setSelectedPersonas(new Set()), 50); }}> Cancel Delete { if (!open) { setDeleteFolderConfirmOpen(false); } else { setDeleteFolderConfirmOpen(open); } }} > Delete Folder Are you sure you want to delete the folder "{folderToDelete?.name}"?

Note: Any personas in this folder will not be deleted - they will still be available under 'All Personas' after folder deletion.
Cancel Delete
{ // Handle dialog open/close state if (!open) { // Close the dialog setMoveToFolderOpen(false); } else { setMoveToFolderOpen(open); } }} > Move to Folder Choose one or more folders to add {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''} to. Personas can belong to multiple folders.
{ const newTargetFolders = new Set(targetFolders); if (checked) { // If "All Personas" is selected, clear all other selections setTargetFolders(new Set([DEFAULT_FOLDER_ID])); } else { newTargetFolders.delete(DEFAULT_FOLDER_ID); setTargetFolders(newTargetFolders); } }} />
{(() => { // Organize folders into hierarchy for the dialog const rootFolders = folders.filter(folder => !folder.parent_folder_id || folder.level === 0); const childMap: Record = {}; folders.forEach(folder => { if (folder.parent_folder_id && folder.level > 0) { if (!childMap[folder.parent_folder_id]) { childMap[folder.parent_folder_id] = []; } childMap[folder.parent_folder_id].push(folder); } }); const handleFolderToggle = (folderId: string, checked: boolean) => { const newTargetFolders = new Set(targetFolders); if (checked) { // If selecting a regular folder, remove "All Personas" if it was selected newTargetFolders.delete(DEFAULT_FOLDER_ID); newTargetFolders.add(folderId); } else { newTargetFolders.delete(folderId); } setTargetFolders(newTargetFolders); }; const renderFolderOption = (folder: Folder, isChild = false) => (
handleFolderToggle(folder._id, !!checked)} />
{/* Render children if any */} {childMap[folder._id] && childMap[folder._id].map(childFolder => renderFolderOption(childFolder, true) )}
); return rootFolders.map(folder => renderFolderOption(folder)); })()}
{/* Filter Dialog */} { // Important: Cancel any pending selections first if (!open) { if (selectedPersonas.size > 0) { setSelectedPersonas(new Set()); } // Then update filter state setIsFilterOpen(false); } else { setIsFilterOpen(open); // When opening, set working filters to active filters setWorkingFilters({...activeFilters}); } }} > { // Prevent interaction with outside elements but do not block pointer events e.preventDefault(); }} > {/* Sticky Header */}
Filter Personas Select attributes to filter personas by. Multiple selections within a category use OR logic, different categories use AND logic. Filter options dynamically update to show only relevant values.
{/* Scrollable Content Area */}
{/* Display number of active filters */} {Object.values(workingFilters).some(arr => arr.length > 0) && (

{Object.values(workingFilters).reduce((count, arr) => count + arr.length, 0)} active filters

)} {/* Basic Demographics Filters */}
{(() => { // Get filter options from filtered personas based on current selections // We need to find which personas match the current filters (excluding the category being viewed) const getFilteredOptions = (currentCategory: keyof FilterState) => { // Create a temporary filter state without the current category const tempFilters = {...workingFilters}; tempFilters[currentCategory] = []; // Clear the current category // Filter the personas using the temporary filters const eligiblePersonas = allPersonas.filter(persona => { // Apply all filters except the one for the current category return Object.entries(tempFilters).every(([category, values]) => { if (values.length === 0) return true; // Skip empty filter arrays const cat = category as keyof FilterState; if (cat === 'techSavviness' && persona.techSavviness !== undefined) { const techLevel = persona.techSavviness < 30 ? 'Low (0-30)' : persona.techSavviness < 70 ? 'Medium (31-70)' : 'High (71-100)'; return values.includes(techLevel); } else if (cat === 'age' && persona.age) { return values.includes(persona.age); } else if (cat === 'gender' && persona.gender) { return values.includes(persona.gender); } else if (cat === 'occupation' && persona.occupation) { return values.includes(persona.occupation); } else if (cat === 'location' && persona.location) { return values.includes(persona.location); } else if (cat === 'ethnicity' && persona.ethnicity) { return values.includes(persona.ethnicity); } return true; // No matching filter for this category }); }); // Now get the filter options from the filtered personas return getFilterOptions(eligiblePersonas); }; // If no filters are active, use all personas const noActiveFilters = Object.values(workingFilters).every(values => values.length === 0); const filterOptions = noActiveFilters ? getFilterOptions(allPersonas) : getFilterOptions(allPersonas); // Default, will be overridden for each category // Render filter section const renderFilterSection = ( title: string, category: keyof FilterState, options: string[], columns: number = 1 ) => { // Include already selected options that might be filtered out // This ensures that users can see and unselect options they've already selected const selectedOptions = workingFilters[category]; // Combine filtered options with selected options const combinedOptions = [...new Set([...options, ...selectedOptions])].sort(); if (combinedOptions.length === 0) return null; return (

{title}

{combinedOptions.map(option => { const isSelected = workingFilters[category].includes(option); const isAvailable = options.includes(option); return (
toggleFilter(category, option)} disabled={!isAvailable && !isSelected} />
); })}
); }; return ( <> {renderFilterSection( 'Gender', 'gender', noActiveFilters ? filterOptions.gender : getFilteredOptions('gender').gender, 3 )} {renderFilterSection( 'Age', 'age', noActiveFilters ? filterOptions.age : getFilteredOptions('age').age, 3 )} {renderFilterSection( 'Ethnicity', 'ethnicity', noActiveFilters ? filterOptions.ethnicity : getFilteredOptions('ethnicity').ethnicity, 2 )} {renderFilterSection( 'Location', 'location', noActiveFilters ? filterOptions.location : getFilteredOptions('location').location, 2 )} {renderFilterSection( 'Occupation', 'occupation', noActiveFilters ? filterOptions.occupation : getFilteredOptions('occupation').occupation, 2 )} {/* Tech Savviness */} {renderFilterSection( 'Tech Savviness', 'techSavviness', noActiveFilters ? filterOptions.techSavviness : getFilteredOptions('techSavviness').techSavviness, 3 )} {/* Folder Assignment */}

Folder Assignment

toggleFilter('folderStatus', 'hasFolder')} />
toggleFilter('folderStatus', 'noFolder')} />
{/* Boolean Attributes */}
); })()}
{/* Sticky Footer */}
{/* LLM Selection Modal for Download */} Select AI Model for Summary Generation Choose which AI model to use for generating persona summaries
) : ( setCreationMode(value as 'manual' | 'ai')}> AI Recruiter Manual Creation {/* Log detailed info about the selected folder */} {console.log(`Rendering AIRecruiter with targetFolderId: ${selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : 'null'}`)} {console.log(`Current folders:`, folders.map(f => ({ id: f.id, name: f.name })))} f.id === selectedFolder)?.name : null} /> f.id === selectedFolder)?.name : null} /> )}
); }; export default SyntheticUsers;