Fix persona generation 504: async flow + remove debug logging

- Backend: /generate-personas-full now returns task_id immediately (202)
  and runs generation as a background asyncio task, delivering results
  via WebSocket task_completed event (bypasses GCP LB 30s timeout)
- Frontend: AIRecruiter listens for ws:task_completed to process personas
  instead of awaiting the long HTTP response
- Remove 53 debug console.log calls from websocketServiceNew.ts including
  session_id exposure and a self-test emit that was firing fake events
- Remove debug logs from WebSocketContextNew, AIRecruiter, personaGenerator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-23 14:00:07 +00:00
parent c00728f375
commit aa4090888d
6 changed files with 301 additions and 615 deletions

View file

@ -1016,108 +1016,48 @@ async def batch_generate_summaries():
return jsonify({"error": "Internal server error", "message": f"An unexpected error occurred: {str(e)}"}), 500
@ai_personas_bp.route('/generate-personas-full', methods=['POST'])
@jwt_required()
async def generate_personas_full():
"""
Unified endpoint for complete persona generation with cancellation support.
Combines the two-stage generation process (basic profiles + completion) into a single
endpoint with proper task management and cancellation checkpoints.
Request body:
{
"audience_brief": "A detailed description of the audience context...",
"research_objective": "Optional research objective...",
"count": 5,
"temperature": 0.8,
"customer_data_session_id": "optional_session_id",
"llm_model": "gemini-3-pro-preview",
"target_folder_id": "optional_folder_id"
}
Returns:
JSON object with task_id and generated personas (when complete)
"""
user_id = get_jwt_identity()
data = await request.get_json() or {}
# Extract and validate parameters
audience_brief = data.get('audience_brief')
if not audience_brief or len(audience_brief.strip()) < 10:
return jsonify({"error": "Missing or invalid audience brief", "message": "Audience brief must be at least 10 characters"}), 400
research_objective = data.get('research_objective')
count = data.get('count', 5)
if count < 1 or count > 10:
return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400
temperature = data.get('temperature', 1.0)
if not (0 <= temperature <= 1.5):
temperature = 1.0
customer_data_session_id = data.get('customer_data_session_id')
llm_model = data.get('llm_model', 'gemini-3-pro-preview')
target_folder_id = data.get('target_folder_id')
try:
# Register current task for cancellation with comprehensive metadata
async with CancellableTask("persona_full_generation", user_id, {
"count": count,
"type": "unified_generation",
"llm_model": llm_model,
"target_folder_id": target_folder_id
}) as task_id:
current_app.logger.info(f"Starting unified persona generation for {count} personas using {llm_model}")
async def _run_persona_generation_bg(
app,
task_id: str,
user_id: str,
audience_brief: str,
research_objective,
count: int,
temperature: float,
customer_data_session_id,
llm_model: str,
target_folder_id
):
"""Background coroutine: generates personas and notifies via WebSocket."""
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
async with app.app_context():
try:
# Stage 1
print(f"🔄 Backend: Unified generation started - Task ID: {task_id}")
# Return task_id immediately so frontend can start cancellation tracking
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
if user_id:
await websocket_manager.emit_to_user(
user_id,
'task_started',
{
'task_id': task_id,
'task_type': 'persona_full_generation',
'message': f'Started generating {count} personas'
}
)
# Stage 1: Generate basic profiles with cancellation check
if asyncio.current_task().cancelled():
raise asyncio.CancelledError("Task was cancelled")
print(f"🔄 Backend: Stage 1 - Generating {count} basic profiles...")
basic_profiles = await generate_basic_personas(
audience_brief=audience_brief,
research_objective=research_objective,
count=count,
temperature=temperature, # Use temperature from request
temperature=temperature,
customer_data_session_id=customer_data_session_id,
llm_model=llm_model
)
print(f"✅ Backend: Stage 1 complete - {len(basic_profiles)} basic profiles generated")
# Stage 2: Complete personas in parallel with cancellation support
# Stage 2
completed_personas = []
failed_personas = []
print(f"🔄 Backend: Stage 2 - Completing {len(basic_profiles)} personas in parallel...")
async def complete_single_persona(i: int, basic_profile: dict):
"""Complete a single persona with cancellation checks."""
try:
# Check for cancellation before starting
if asyncio.current_task().cancelled():
raise asyncio.CancelledError("Task was cancelled")
raise asyncio.CancelledError()
print(f"🔄 Backend: Completing persona {i+1}/{len(basic_profiles)}: {basic_profile.get('name', 'Unknown')}")
# Complete the persona
persona_data = await generate_persona(
basic_persona=basic_profile,
temperature=temperature,
@ -1127,146 +1067,168 @@ async def generate_personas_full():
research_objective=research_objective
)
# Check cancellation before summary generation
if asyncio.current_task().cancelled():
raise asyncio.CancelledError("Task was cancelled")
# Generate AI summary
raise asyncio.CancelledError()
try:
summary_data = await generate_persona_summary(
persona_data=persona_data,
temperature=temperature,
llm_model=llm_model
)
persona_data['aiSynthesizedBio'] = summary_data['aiSynthesizedBio']
persona_data['qualitativeAttributes'] = summary_data['qualitativeAttributes']
persona_data['topPersonalityTraits'] = summary_data['topPersonalityTraits']
except Exception as summary_error:
current_app.logger.warning(f"Failed to generate summary for persona: {str(summary_error)}")
# Add generation prompts
app.logger.warning(f"Failed to generate summary for persona: {str(summary_error)}")
if audience_brief:
persona_data['audience_brief'] = audience_brief
if research_objective:
persona_data['research_objective'] = research_objective
# Check cancellation before database save
if asyncio.current_task().cancelled():
raise asyncio.CancelledError("Task was cancelled")
# Remove generated ID before saving
raise asyncio.CancelledError()
if 'id' in persona_data:
del persona_data['id']
# Save to database
persona_id = await Persona.create(persona_data, user_id)
persona_data['_id'] = str(persona_id)
print(f"✅ Backend: Persona {i+1}/{len(basic_profiles)} completed: {persona_data.get('name')}")
return {'success': True, 'persona': persona_data, 'index': i}
except asyncio.CancelledError:
raise # Re-raise cancellation
raise
except Exception as persona_error:
print(f"❌ Backend: Failed to complete persona {i+1}: {persona_error}")
return {
'success': False,
'index': i,
'name': basic_profile.get('name', f'Persona {i+1}'),
'error': str(persona_error)
}
# Execute all persona completions in parallel
try:
completion_tasks = [
complete_single_persona(i, profile)
for i, profile in enumerate(basic_profiles)
]
# Wait for all personas to complete or fail
results = await asyncio.gather(*completion_tasks, return_exceptions=True)
# Process results
for result in results:
if isinstance(result, asyncio.CancelledError):
raise result # Re-raise cancellation
elif isinstance(result, Exception):
failed_personas.append({
'index': -1,
'name': 'Unknown',
'error': str(result)
})
elif result['success']:
completed_personas.append(result['persona'])
else:
failed_personas.append({
'index': result['index'],
'name': result['name'],
'error': result['error']
})
except asyncio.CancelledError:
raise # Re-raise cancellation
return {'success': False, 'index': i, 'name': basic_profile.get('name', f'Persona {i+1}'), 'error': str(persona_error)}
results = await asyncio.gather(
*[complete_single_persona(i, p) for i, p in enumerate(basic_profiles)],
return_exceptions=True
)
for result in results:
if isinstance(result, asyncio.CancelledError):
raise result
elif isinstance(result, Exception):
failed_personas.append({'index': -1, 'name': 'Unknown', 'error': str(result)})
elif result['success']:
completed_personas.append(result['persona'])
else:
failed_personas.append({'index': result['index'], 'name': result['name'], 'error': result['error']})
print(f"✅ Backend: Stage 2 complete - {len(completed_personas)} personas completed, {len(failed_personas)} failed")
# Check cancellation before folder assignment
if asyncio.current_task().cancelled():
raise asyncio.CancelledError("Task was cancelled")
# Add personas to target folder if specified
# Folder assignment
if target_folder_id and completed_personas:
try:
from app.models.folder import Folder
persona_ids = [p['_id'] for p in completed_personas]
await Folder.add_personas_batch(target_folder_id, persona_ids)
print(f"✅ Backend: Added {len(persona_ids)} personas to folder {target_folder_id}")
await Folder.add_personas_batch(target_folder_id, [p['_id'] for p in completed_personas])
except Exception as folder_error:
current_app.logger.warning(f"Failed to add personas to folder: {folder_error}")
# Clean up customer data if provided
app.logger.warning(f"Failed to add personas to folder: {folder_error}")
# Customer data cleanup
if customer_data_session_id:
try:
customer_data_service.cleanup_session(customer_data_session_id)
except Exception as cleanup_error:
current_app.logger.warning(f"Failed to cleanup customer data: {cleanup_error}")
except Exception:
pass
print(f"🎉 Backend: Unified generation complete - {len(completed_personas)} personas created")
# Emit completion event via WebSocket
if user_id:
await websocket_manager.emit_to_user(
user_id,
'task_completed',
{
'task_id': task_id,
'task_type': 'persona_full_generation',
'message': f'Successfully generated {len(completed_personas)} personas',
'personas_created': len(completed_personas),
'errors': len(failed_personas)
}
)
return jsonify({
"message": f"Successfully generated and saved {len(completed_personas)} personas using {llm_model}",
"personas": completed_personas,
"persona_ids": [p['_id'] for p in completed_personas],
"task_id": task_id,
"errors": failed_personas if failed_personas else None,
"partial_success": len(failed_personas) > 0 and len(completed_personas) > 0
}), 201
except asyncio.CancelledError:
current_app.logger.info(f"Unified persona generation cancelled for user {user_id}")
return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499
except PersonaGenerationError as e:
current_app.logger.error(f"Unified persona generation error: {str(e)}")
return jsonify({"error": "Failed to generate personas", "message": str(e)}), 500
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'persona_full_generation',
'message': f'Successfully generated {len(completed_personas)} personas',
'personas': completed_personas,
'errors': failed_personas if failed_personas else None,
'partial_success': len(failed_personas) > 0 and len(completed_personas) > 0
})
except asyncio.CancelledError:
app.logger.info(f"Persona generation task {task_id} cancelled")
await websocket_manager.emit_to_user(user_id, 'task_cancelled', {
'task_id': task_id,
'task_type': 'persona_full_generation',
'message': 'Generation cancelled'
})
except Exception as e:
app.logger.error(f"Persona generation task {task_id} failed: {str(e)}")
await websocket_manager.emit_to_user(user_id, 'task_failed', {
'task_id': task_id,
'task_type': 'persona_full_generation',
'message': str(e)
})
@ai_personas_bp.route('/generate-personas-full', methods=['POST'])
@jwt_required()
async def generate_personas_full():
"""
Async persona generation: returns task_id immediately (202),
delivers results via WebSocket task_completed event.
"""
user_id = get_jwt_identity()
data = await request.get_json() or {}
audience_brief = data.get('audience_brief')
if not audience_brief or len(audience_brief.strip()) < 10:
return jsonify({"error": "Missing or invalid audience brief", "message": "Audience brief must be at least 10 characters"}), 400
research_objective = data.get('research_objective')
count = data.get('count', 5)
if count < 1 or count > 10:
return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400
temperature = data.get('temperature', 1.0)
if not (0 <= temperature <= 1.5):
temperature = 1.0
customer_data_session_id = data.get('customer_data_session_id')
llm_model = data.get('llm_model', 'gemini-3-pro-preview')
target_folder_id = data.get('target_folder_id')
try:
from app.services.task_manager import get_task_manager
from app.websocket_manager_async import get_async_websocket_manager
task_manager = get_task_manager()
task_id = task_manager.generate_task_id()
app = current_app._get_current_object()
bg_task = asyncio.create_task(
_run_persona_generation_bg(
app, task_id, user_id,
audience_brief, research_objective,
count, temperature, customer_data_session_id,
llm_model, target_folder_id
)
)
await task_manager.register_task(bg_task, 'persona_full_generation', user_id, {
'count': count,
'llm_model': llm_model,
'target_folder_id': target_folder_id
}, task_id=task_id)
current_app.logger.info(f"Starting unified persona generation for {count} personas using {llm_model}")
websocket_manager = get_async_websocket_manager()
await websocket_manager.emit_to_user(user_id, 'task_started', {
'task_id': task_id,
'task_type': 'persona_full_generation',
'message': f'Started generating {count} personas'
})
return jsonify({
'task_id': task_id,
'message': f'Generation started for {count} personas'
}), 202
except Exception as e:
current_app.logger.error(f"Unexpected error in unified persona generation: {str(e)}")
return jsonify({"error": "Internal server error", "message": "An unexpected error occurred"}), 500
current_app.logger.error(f"Failed to start persona generation: {str(e)}")
return jsonify({"error": "Internal server error", "message": "Failed to start generation"}), 500
@ai_personas_bp.route('/upload-customer-data', methods=['POST'])

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { z } from "zod";
import { Users } from 'lucide-react';
import { toast } from 'sonner';
@ -34,6 +34,8 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr
const [generationToastId, setGenerationToastId] = useState<string | null>(null);
const [isProgressModalOpen, setIsProgressModalOpen] = useState(false);
const [progressModalDescription, setProgressModalDescription] = useState('');
// Ref to track current task_id in WebSocket event handlers (avoids stale closure)
const taskIdRef = useRef<string | null>(null);
// Check URL params for state restoration
useEffect(() => {
@ -61,186 +63,127 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr
}
}, [generationState.isGenerating, generationState.isCancelling, generationToastId]);
// Handle WebSocket task completion — personas arrive here
useEffect(() => {
const handleTaskCompleted = (event: CustomEvent) => {
const data = event.detail;
if (data.task_id !== taskIdRef.current) return;
generationControls.completeGeneration();
if (generationToastId) {
toast.dismiss(generationToastId);
setGenerationToastId(null);
}
const personas: Persona[] = data.personas || [];
const errors = data.errors || [];
if (personas.length > 0) {
if (data.partial_success && errors.length > 0) {
toast.success("Some personas generated successfully", {
description: `${personas.length} created, ${errors.length} failed.`,
duration: 8000
});
} else {
toast.success("Personas generated and saved successfully", {
description: `${personas.length} personas saved${targetFolderName ? ` to "${targetFolderName}"` : ''}.`
});
}
setTimeout(() => navigate('/synthetic-users?mode=view'), 1500);
} else {
generationControls.failGeneration("No personas were generated");
toast.error("Failed to generate personas", { description: "No personas were returned." });
}
};
const handleTaskFailed = (event: CustomEvent) => {
const data = event.detail;
if (data.task_id !== taskIdRef.current) return;
const errorMessage = data.message || "Please try again or adjust your parameters";
generationControls.failGeneration(errorMessage);
if (generationToastId) {
toast.dismiss(generationToastId);
setGenerationToastId(null);
}
toast.error("Failed to generate personas", { description: errorMessage, duration: 6000 });
};
window.addEventListener('ws:task_completed', handleTaskCompleted as EventListener);
window.addEventListener('ws:task_failed', handleTaskFailed as EventListener);
return () => {
window.removeEventListener('ws:task_completed', handleTaskCompleted as EventListener);
window.removeEventListener('ws:task_failed', handleTaskFailed as EventListener);
};
}, [generationToastId, targetFolderName]);
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
// Start generation without task ID - will be set when real task ID arrives via WebSocket
generationControls.startGeneration();
// Validate count before proceeding
const count = parseInt(values.personaCount);
if (isNaN(count) || count < 1 || count > 10) {
toast.error("Invalid number of personas", {
description: "Please enter a number between 1 and 10"
});
toast.error("Invalid number of personas", { description: "Please enter a number between 1 and 10" });
generationControls.resetGeneration();
return;
}
// Adjust the expected time based on the count
const estimatedTime = count <= 2 ? "30-60 seconds" :
count <= 4 ? "1-2 minutes" :
count <= 6 ? "2-3 minutes" :
"3-5 minutes";
count <= 4 ? "1-2 minutes" :
count <= 6 ? "2-3 minutes" : "3-5 minutes";
// Open progress modal with description
setProgressModalDescription(`Creating ${count} synthetic persona${count !== 1 ? 's' : ''} based on your criteria. Estimated time: ${estimatedTime}.`);
setIsProgressModalOpen(true);
const toastId = toast.info("Generating AI personas", {
description: `Creating ${count} synthetic personas based on your brief. This may take ${estimatedTime}. Please be patient.`,
description: `Creating ${count} synthetic personas. This may take ${estimatedTime}.`,
duration: 10000
});
setGenerationToastId(toastId);
// Show folder info in toast if available
if (targetFolderId && targetFolderName) {
console.log(`Target folder for new personas: ID=${targetFolderId}, Name=${targetFolderName}`);
toast.info(`Creating personas in "${targetFolderName}" folder`, {
duration: 3000
});
} else {
console.log("No target folder specified for new personas");
toast.info(`Creating personas in "${targetFolderName}" folder`, { duration: 3000 });
}
// Log which model is being used for generation
console.log(`🤖 Starting persona generation with model: ${values.llm_model || 'gemini-2.5-pro'}`);
const response = await generateSyntheticPersonas(
values.audienceBrief,
// Quick call — returns task_id immediately (202), generation runs in background
const { task_id } = await generateSyntheticPersonas(
values.audienceBrief,
values.researchObjective,
count,
values.dataFile,
targetFolderId,
values.llm_model,
// Pass a callback to set task ID as soon as we get it
(taskId: string) => {
console.log('🔥 Early task ID callback:', taskId);
taskIdRef.current = taskId;
generationControls.setTaskId(taskId);
},
values.temperature
).catch(error => {
// Check if generation was cancelled early (before first API response)
if (generationState.taskId?.startsWith('temp-') && !generationState.isGenerating) {
console.log('🔥 Generation was cancelled early - not propagating error');
return null; // Don't propagate the error if cancelled
}
throw error; // Re-throw other errors
});
// Handle early cancellation
if (!response) {
console.log('🔥 Generation was cancelled - no response to process');
return; // Exit early if cancelled
}
// Check if response includes task ID and set it
console.log('🔥 Full response:', response);
console.log('🔥 Response task_id:', response.task_id);
if (response.task_id) {
console.log('🔥 Setting task ID:', response.task_id);
generationControls.setTaskId(response.task_id);
} else {
console.log('🔥 No task_id in response!');
}
// Extract personas from the response
const personas = response.personas || response;
// Check for partial success (some personas generated, some failed)
if (personas && personas.length > 0) {
// Log successful generation with model info
console.log(`✅ Successfully generated ${personas.length} personas using model: ${values.llm_model || 'gemini-2.5-pro'}`);
// Mark generation as complete
generationControls.completeGeneration();
// Dismiss the generation toast
if (generationToastId) {
toast.dismiss(generationToastId);
setGenerationToastId(null);
}
// Check if we got a response with partial success info
if (response.partial_success || (response.errors && response.errors.length > 0)) {
// Some personas succeeded but others failed
toast.success("Some personas generated successfully", {
description: `${personas.length} synthetic personas were created using ${values.llm_model || 'Gemini 3 Pro'}. ${response.errors?.length || 0} failed due to timeout or other errors.`,
duration: 8000
});
// Show details about the failures
if (response.errors && response.errors.length > 0) {
setTimeout(() => {
toast.error("Some personas failed to generate", {
description: `${response.errors.length} personas timed out. The server took too long to generate them. The successfully generated personas have been saved${targetFolderId ? ` in the selected folder` : ''}.`,
duration: 10000
});
}, 1000);
}
} else {
// All personas succeeded
toast.success("Personas generated and saved successfully", {
description: `${personas.length} synthetic personas have been created using ${values.llm_model || 'Gemini 3 Pro'} and saved ${targetFolderId ? `to the "${targetFolderName}" folder` : 'to the database'}.`
});
}
// Wait a moment before navigating to show completion
setTimeout(() => {
navigate('/synthetic-users?mode=view');
}, 1500);
} else {
throw new Error("No personas were generated");
}
);
taskIdRef.current = task_id;
generationControls.setTaskId(task_id);
} catch (error) {
// Check if this was a cancellation (expected behavior)
if (error.response?.status === 499) {
// Task was cancelled - this is handled by the WebSocket cancellation system
return;
}
console.error(`❌ Error generating personas using model: ${values.llm_model || 'gemini-2.5-pro'}:`, error);
if (error.response?.status === 499) return;
let errorMessage = "Please try again or adjust your parameters";
let errorTitle = "Failed to generate personas";
// Check for specific error types
if (error.code === "ECONNABORTED" || error.message?.includes("timeout") || error.response?.status === 504) {
errorTitle = "Generation timeout";
errorMessage = "AI persona generation timed out. This often happens when generating multiple complex personas. Try generating fewer personas (2-3) or try again later.";
} else if (error.response?.status === 500) {
errorTitle = "Server error";
// Try to extract the error message from the response if available
if (error.response?.data?.message) {
errorMessage = error.response.data.message;
} else if (error.response?.data?.error) {
errorMessage = error.response.data.error;
} else {
errorMessage = "The server encountered an error processing your request. Please try again later.";
}
let errorTitle = "Failed to start generation";
if (error.response?.status === 400) {
errorMessage = error.response.data?.message || errorMessage;
} else if (error.response?.status === 401) {
errorTitle = "Authentication required";
errorMessage = "Please log in to generate personas.";
} else if (error.message?.includes("504 Deadline Exceeded")) {
errorTitle = "Generation timeout";
errorMessage = "The AI model took too long to generate personas. Try generating fewer personas or simplify your brief.";
} else if (error instanceof Error) {
errorMessage = error.message;
}
// Mark generation as failed
generationControls.failGeneration(errorMessage);
// Dismiss the generation toast
if (generationToastId) {
toast.dismiss(generationToastId);
setGenerationToastId(null);
}
toast.error(errorTitle, {
description: errorMessage,
duration: 6000 // Show for longer to ensure user sees it
});
toast.error(errorTitle, { description: errorMessage, duration: 6000 });
}
}

View file

@ -24,25 +24,17 @@ export function WebSocketProvider({ children }: WebSocketProviderProps) {
// GPT-5 FIX: Get token from localStorage directly as fallback
const getAccessToken = () => {
const authToken = token || localStorage.getItem('auth_token');
console.log('🔧 [GPT-5 Context] Getting token:', authToken ? 'Found' : 'Missing');
return authToken || '';
return token || localStorage.getItem('auth_token') || '';
};
useEffect(() => {
if (!socketInitialized) {
console.log('🔧 [GPT-5 Context] Initializing singleton socket');
initSocket(getAccessToken);
socketInitialized = true;
}
// Only connect if we have a valid token
const currentToken = getAccessToken();
if (currentToken) {
console.log('🔧 [GPT-5 Context] Connecting socket with token');
if (getAccessToken()) {
connectSocket();
} else {
console.log('🔧 [GPT-5 Context] No token available, skipping WebSocket connection');
}
return () => {

View file

@ -453,7 +453,7 @@ export const aiPersonasApi = {
llm_model: llmModel || 'gemini-2.5-pro',
target_folder_id: targetFolderId
}, {
timeout: 180000 // 3 minutes for complete generation
timeout: 10000 // 10 seconds — endpoint returns immediately with task_id
});
}
};

View file

@ -1,10 +1,9 @@
/**
* GPT-5 Singleton WebSocket Service
* Fixes listener binding issues by ensuring stable socket instance and re-binding on every connect
* Singleton WebSocket Service
*/
import { io, Socket } from "socket.io-client";
const BASE_URL = import.meta.env.DEV
const BASE_URL = import.meta.env.DEV
? "http://localhost:5137"
: (import.meta.env.VITE_WEBSOCKET_URL || window.location.origin);
@ -14,16 +13,12 @@ let socket: Socket | null = null;
let currentRoom: string | null = null;
let coreListenersBound = false;
// GPT-5 FIX: Pass token getter function to ensure fresh tokens on reconnect
export function initSocket(getToken: () => string): Socket {
if (socket) {
// Keep token fresh for future reconnects
socket.io.opts.auth = { token: getToken() };
return socket;
}
console.log('🔧 [GPT-5] Creating singleton socket:', BASE_URL, SOCKET_PATH);
socket = io(BASE_URL, {
path: SOCKET_PATH,
transports: ["websocket"],
@ -32,125 +27,77 @@ export function initSocket(getToken: () => string): Socket {
timeout: 60000,
pingInterval: 45000,
pingTimeout: 120000,
// Using auth callback guarantees latest token on every (re)connect
auth: (cb) => cb({ token: getToken() }),
});
// Always refresh token before reconnect attempts
socket.io.on("reconnect_attempt", () => {
console.log('🔧 [GPT-5] Reconnect attempt - refreshing token');
socket!.io.opts.auth = { token: getToken() };
});
// GPT-5 CRITICAL: Bind listeners on EVERY connect (initial + reconnects)
const onConnected = () => {
console.log('🔧 [GPT-5] Socket connected, rebinding listeners and rejoining room');
bindCoreListeners(); // idempotent
if (currentRoom) rejoinRoom(); // keep room after reconnect
};
socket.on("connect", onConnected);
socket.on("connect", () => {
bindCoreListeners();
if (currentRoom) rejoinRoom();
});
// GPT-5 FIX: Route all events through onAny since specific listeners are broken
socket.onAny((event, ...args) => {
console.log(`🔧 [GPT-5 onAny] ${event}:`, args);
// Route to specific handlers manually since socket.on() doesn't work
const payload = args[0]; // First argument is always the payload
const payload = args[0];
switch (event) {
case 'joined_focus_group':
console.log('🔧 [GPT-5] *** ROUTING joined_focus_group from onAny ***');
window.dispatchEvent(new CustomEvent("ws:joined_focus_group", { detail: payload }));
break;
case 'left_focus_group':
console.log('🔧 [GPT-5] *** ROUTING left_focus_group from onAny ***');
window.dispatchEvent(new CustomEvent("ws:left_focus_group", { detail: payload }));
break;
case 'message_update':
console.log('🔧 [GPT-5] *** ROUTING message_update from onAny ***');
try {
window.dispatchEvent(new CustomEvent("ws:message_update", { detail: payload }));
console.log('🔧 [GPT-5] DISPATCHED window event ws:message_update SUCCESS (via onAny)');
} catch (error) {
console.error('🔧 [GPT-5] ERROR dispatching window event (via onAny):', error);
}
window.dispatchEvent(new CustomEvent("ws:message_update", { detail: payload }));
break;
case 'ai_status_update':
console.log('🔧 [GPT-5] *** ROUTING ai_status_update from onAny ***');
window.dispatchEvent(new CustomEvent("ws:ai_status_update", { detail: payload }));
break;
case 'moderator_status_update':
console.log('🔧 [GPT-5] *** ROUTING moderator_status_update from onAny ***');
window.dispatchEvent(new CustomEvent("ws:moderator_status_update", { detail: payload }));
break;
case 'theme_update':
console.log('🔧 [GPT-5] *** ROUTING theme_update from onAny ***');
window.dispatchEvent(new CustomEvent("ws:theme_update", { detail: payload }));
break;
case 'focus_group_update':
console.log('🔧 [GPT-5] *** ROUTING focus_group_update from onAny ***');
window.dispatchEvent(new CustomEvent("ws:focus_group_update", { detail: payload }));
break;
case 'mode_event_update':
console.log('🔧 [GPT-5] *** ROUTING mode_event_update from onAny ***');
window.dispatchEvent(new CustomEvent("ws:mode_event_update", { detail: payload }));
break;
case 'connected':
console.log('🔧 [GPT-5] *** ROUTING connected from onAny ***');
// Handle connected events if needed
break;
case 'task_started':
console.log('🔧 [GPT-5] *** ROUTING task_started from onAny ***');
window.dispatchEvent(new CustomEvent("ws:task_started", { detail: payload }));
break;
case 'task_cancelled':
console.log('🔧 [GPT-5] *** ROUTING task_cancelled from onAny ***');
window.dispatchEvent(new CustomEvent("ws:task_cancelled", { detail: payload }));
break;
case 'task_completed':
console.log('🔧 [GPT-5] *** ROUTING task_completed from onAny ***');
window.dispatchEvent(new CustomEvent("ws:task_completed", { detail: payload }));
break;
case 'task_failed':
console.log('🔧 [GPT-5] *** ROUTING task_failed from onAny ***');
window.dispatchEvent(new CustomEvent("ws:task_failed", { detail: payload }));
break;
case 'bulk_export_progress':
console.log('🔧 [GPT-5] *** ROUTING bulk_export_progress from onAny ***');
window.dispatchEvent(new CustomEvent("ws:bulk_export_progress", { detail: payload }));
break;
case 'error':
console.error('🔧 [GPT-5] *** ROUTING error from onAny ***', payload);
console.error('[WebSocket] Error:', payload);
break;
case 'auth_error':
console.error('🔧 [GPT-5] *** ROUTING auth_error from onAny ***', payload);
console.error('[WebSocket] Auth error');
window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload }));
break;
}
});
// Connection debugging
socket.on("connect_error", (error) => {
console.error('🔧 [GPT-5] Connect error:', error);
console.error('[WebSocket] Connect error:', error.message);
});
socket.on("disconnect", (reason) => {
console.log('🔧 [GPT-5] Disconnected:', reason);
if (import.meta.env.DEV) console.log('[WebSocket] Disconnected:', reason);
});
return socket;
@ -158,179 +105,65 @@ export function initSocket(getToken: () => string): Socket {
export function connectSocket() {
if (socket && !socket.connected) {
console.log('🔧 [GPT-5] Connecting socket');
socket.connect();
}
}
export function disconnectSocket() {
if (socket) {
console.log('🔧 [GPT-5] Disconnecting socket');
socket.disconnect();
currentRoom = null;
}
}
export function joinFocusGroup(focus_group_id: string, ack?: (resp: any) => void) {
console.log('🔧 [GPT-5] Joining focus group:', focus_group_id);
currentRoom = focus_group_id;
if (!socket?.connected) {
console.log('🔧 [GPT-5] Socket not connected, will auto-rejoin on connect');
// Force connection and try again
connectSocket();
setTimeout(() => {
if (socket?.connected) {
console.log('🔧 [GPT-5] Retrying join after connection established');
socket.emit("join_focus_group", { focus_group_id }, (resp: any) => {
console.log('🔧 [GPT-5] join_focus_group RETRY ACK:', resp);
ack?.(resp);
});
} else {
console.log('🔧 [GPT-5] Still not connected, will rejoin on next connect event');
socket.emit("join_focus_group", { focus_group_id }, (resp: any) => ack?.(resp));
}
}, 1000);
return;
}
socket.emit("join_focus_group", { focus_group_id }, (resp: any) => {
console.log('🔧 [GPT-5] join_focus_group ACK:', resp);
ack?.(resp);
});
socket.emit("join_focus_group", { focus_group_id }, (resp: any) => ack?.(resp));
}
export function leaveFocusGroup(focus_group_id: string) {
console.log('🔧 [GPT-5] Leaving focus group:', focus_group_id);
if (currentRoom === focus_group_id) {
currentRoom = null;
}
if (socket?.connected) {
socket.emit("leave_focus_group", { focus_group_id });
}
}
// GPT-5 FIX: Auto-rejoin room on reconnect
function rejoinRoom() {
if (!socket?.connected || !currentRoom) return;
console.log('🔧 [GPT-5] Auto-rejoining room after reconnect:', currentRoom);
socket.emit("join_focus_group", { focus_group_id: currentRoom });
}
// GPT-5 CRITICAL: Ensure we never lose handlers across reconnects or AI mode toggles
function bindCoreListeners() {
if (!socket) {
console.log('🔧 [GPT-5] bindCoreListeners called but socket is null!');
return;
}
if (!socket) return;
// GPT-5 FIX: Always rebind listeners on reconnect - don't let flag prevent rebinding
if (coreListenersBound) {
console.log('🔧 [GPT-5] Listeners already bound, but rebinding anyway for safety');
}
// Already bound — listeners are re-added via onAny, no need to duplicate
if (coreListenersBound) return;
console.log('🔧 [GPT-5] bindCoreListeners called - socket exists, binding listeners');
// IMPORTANT: Use stable function references so off() later removes exactly these
const onJoined = (payload: any) => {
console.log('🔧 [GPT-5] joined_focus_group:', payload);
window.dispatchEvent(new CustomEvent("ws:joined_focus_group", { detail: payload }));
};
const onLeft = (payload: any) => {
console.log('🔧 [GPT-5] left_focus_group:', payload);
window.dispatchEvent(new CustomEvent("ws:left_focus_group", { detail: payload }));
};
const onMsg = (payload: any) => {
console.log('🔧 [GPT-5] *** MESSAGE_UPDATE LISTENER FIRED! ***');
console.log('🔧 [GPT-5] message_update payload:', payload);
console.log('🔧 [GPT-5] DISPATCHING window event ws:message_update');
try {
window.dispatchEvent(new CustomEvent("ws:message_update", { detail: payload }));
console.log('🔧 [GPT-5] DISPATCHED window event ws:message_update SUCCESS');
} catch (error) {
console.error('🔧 [GPT-5] ERROR dispatching window event:', error);
}
};
const onAI = (payload: any) => {
console.log('🔧 [GPT-5] ai_status_update:', payload);
console.log('🔧 [GPT-5] DISPATCHING window event ws:ai_status_update');
window.dispatchEvent(new CustomEvent("ws:ai_status_update", { detail: payload }));
console.log('🔧 [GPT-5] DISPATCHED window event ws:ai_status_update');
};
const onMod = (payload: any) => {
console.log('🔧 [GPT-5] moderator_status_update:', payload);
window.dispatchEvent(new CustomEvent("ws:moderator_status_update", { detail: payload }));
};
const onTheme = (payload: any) => {
console.log('🔧 [GPT-5] theme_update:', payload);
window.dispatchEvent(new CustomEvent("ws:theme_update", { detail: payload }));
};
const onFG = (payload: any) => {
console.log('🔧 [GPT-5] focus_group_update:', payload);
window.dispatchEvent(new CustomEvent("ws:focus_group_update", { detail: payload }));
};
const onModeEvent = (payload: any) => {
console.log('🔧 [GPT-5] mode_event_update:', payload);
window.dispatchEvent(new CustomEvent("ws:mode_event_update", { detail: payload }));
};
const onAuthError = (payload: any) => {
console.error('🔧 [GPT-5] auth_error:', payload);
socket.on("auth_error", (payload: any) => {
console.error('[WebSocket] Auth error');
window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload }));
};
const onBulkExportProgress = (payload: any) => {
console.log('🔧 [GPT-5] bulk_export_progress:', payload);
window.dispatchEvent(new CustomEvent("ws:bulk_export_progress", { detail: payload }));
};
// Bind all listeners
console.log('🔧 [GPT-5] BINDING specific listeners to socket');
socket.on("joined_focus_group", onJoined);
socket.on("left_focus_group", onLeft);
socket.on("message_update", onMsg);
socket.on("ai_status_update", onAI);
socket.on("moderator_status_update", onMod);
socket.on("theme_update", onTheme);
socket.on("focus_group_update", onFG);
socket.on("mode_event_update", onModeEvent);
socket.on("auth_error", onAuthError);
socket.on("bulk_export_progress", onBulkExportProgress);
console.log('🔧 [GPT-5] BOUND specific listeners to socket');
// GPT-5 DEBUG: Verify listeners are actually attached
console.log('🔧 [GPT-5] Socket listeners after binding:', socket.listeners('message_update').length);
console.log('🔧 [GPT-5] Socket hasListeners message_update:', socket.hasListeners('message_update'));
// GPT-5 TEST: Emit a test event to ourselves to verify binding
setTimeout(() => {
if (socket?.connected) {
console.log('🔧 [GPT-5] SELF-TEST: Emitting test event');
(socket as any).emit('message_update', { test: 'self-emit-test' });
}
}, 1000);
// Handle connection events
socket.on("connected", (payload: any) => {
console.log('🔧 [GPT-5] connected:', payload);
});
socket.on("error", (error: any) => {
console.error('🔧 [GPT-5] socket error:', error);
console.error('[WebSocket] Socket error:', error?.message || error);
});
coreListenersBound = true;
}
// Utility to get current socket (for debugging)
export function getSocket() {
return socket;
}
@ -341,4 +174,4 @@ export function getSocketId() {
export function isConnected() {
return socket?.connected ?? false;
}
}

View file

@ -15,99 +15,55 @@ import { aiPersonasApi, personasApi, foldersApi } from "@/lib/api";
* @param llmModel Optional LLM model to use for generation
* @returns Array of generated personas
*/
/**
* Start async persona generation. Returns task_id immediately (202).
* Results are delivered via WebSocket task_completed event.
*/
export async function generateSyntheticPersonas(
brief: string,
brief: string,
researchObjective?: string,
count: number,
count: number = 5,
file?: FileList,
targetFolderId?: string | null,
llmModel?: string,
onTaskIdReceived?: (taskId: string) => void,
temperature?: number
): Promise<{personas: Persona[], task_id?: string, partial_success?: boolean, errors?: any[]}> {
// Debug logging for folder and model
console.log(`generateSyntheticPersonas called with targetFolderId: ${targetFolderId || 'none'}`);
console.log(`🔄 generateSyntheticPersonas using model: ${llmModel || 'gemini-3-pro-preview'}`);
try {
// We'll use the two-stage approach
console.log(`Generating ${count} synthetic personas using two-stage approach...`);
// First, check if the brief is too short to be useful
if (brief.trim().length < 10) {
throw new Error("Audience brief is too short. Please provide more context for better persona generation.");
}
// Upload customer data if files provided
let customerDataSessionId: string | undefined;
if (file && file.length > 0) {
console.log(`Uploading ${file.length} customer data files...`);
try {
const uploadResponse = await aiPersonasApi.uploadCustomerData(file);
customerDataSessionId = uploadResponse.data.session_id;
console.log(`Customer data uploaded with session ID: ${customerDataSessionId}`);
} catch (error) {
console.error('Failed to upload customer data:', error);
throw new Error("Failed to upload customer data files. Please try again.");
}
}
// Use the new unified generation endpoint
console.log('🔥 Calling generatePersonasFull...');
console.log(`🌡️ Using temperature: ${temperature || 1.0}`);
const response = await aiPersonasApi.generatePersonasFull(
brief, // Audience brief
researchObjective, // Research objective
count, // Number of personas to generate
temperature || 1.0, // Temperature (default to 1.0 if not specified)
customerDataSessionId, // Customer data session ID
llmModel, // LLM model
targetFolderId // Target folder ID
);
// Call the task ID callback immediately since unified endpoint returns task_id right away
if (response.data.task_id && onTaskIdReceived) {
console.log('🔥 Unified endpoint - calling onTaskIdReceived with:', response.data.task_id);
onTaskIdReceived(response.data.task_id);
}
if (response.data) {
// Handle partial success (some personas succeeded, some failed)
const hasPartialSuccess = response.data.partial_success === true;
const hasPersonas = response.data.personas && response.data.personas.length > 0;
const hasErrors = response.data.errors && response.data.errors.length > 0;
// Include task_id in response if available
const result = {
personas: response.data.personas,
task_id: response.data.task_id,
partial_success: hasPartialSuccess,
errors: response.data.errors
};
if (hasPersonas) {
console.log(`Generated ${response.data.personas.length} personas with unified endpoint${hasErrors ? ` (${response.data.errors.length} failed)` : ''}`);
// Unified endpoint handles folder assignment and cleanup internally
return result;
} else if (hasErrors) {
// If we have errors but no personas, throw an error
throw new Error(`Failed to generate personas: ${response.data.errors.length} generation attempts failed.`);
} else {
throw new Error("No personas returned from API");
}
} else {
throw new Error("Invalid response format from API");
}
} catch (error) {
// Note: Cleanup is now handled by the unified backend endpoint
// Don't log 499 errors as they are successful cancellations
if (error.response?.status !== 499) {
console.error("Error generating AI personas:", error);
}
throw error;
): Promise<{ task_id: string }> {
if (brief.trim().length < 10) {
throw new Error("Audience brief is too short. Please provide more context for better persona generation.");
}
let customerDataSessionId: string | undefined;
if (file && file.length > 0) {
try {
const uploadResponse = await aiPersonasApi.uploadCustomerData(file);
customerDataSessionId = uploadResponse.data.session_id;
} catch (error) {
console.error('Failed to upload customer data:', error);
throw new Error("Failed to upload customer data files. Please try again.");
}
}
const response = await aiPersonasApi.generatePersonasFull(
brief,
researchObjective,
count,
temperature || 1.0,
customerDataSessionId,
llmModel,
targetFolderId
);
const task_id = response.data.task_id;
if (!task_id) {
throw new Error("No task_id returned from server");
}
if (onTaskIdReceived) {
onTaskIdReceived(task_id);
}
return { task_id };
}
/**