diff --git "a/static/script.js" "b/static/script.js" new file mode 100644--- /dev/null +++ "b/static/script.js" @@ -0,0 +1,2821 @@ +// Global variables +const generateButton = document.getElementById('generate_button'); +const stopButton = document.getElementById('stop_button'); +const clearButton = document.getElementById('clear_button'); +const progressBar = document.getElementById('progress_bar'); +const itemsContainer = document.getElementById('items-container'); +const agent2Table = document.getElementById("agent2PropertiesTable"); +const addAgent2Button = document.getElementById("add_agent_2_property_button"); +const agent3PropertiesTable = document.getElementById('agent3PropertiesTable'); +const addAgent3Button = document.getElementById('add_agent_3_property_button'); +const modelChoice = document.getElementById('model_choice'); +const apiKeyInput = document.getElementById('api_key'); +const generationStatus = document.getElementById('generation_status'); // Get generation status element +const autoGenerateButton = document.getElementById('auto_generate_button'); // Get AutoGenerate button element + +// Log display related elements +const generatorLog = document.getElementById('generator-log'); +const validatorLog = document.getElementById('validator-log'); +const scorerLog = document.getElementById('scorer-log'); + +// Get session ID from URL +const sessionId = window.location.pathname.split('/')[1]; +console.log(`Using session ID: ${sessionId}`); + +// Global socket variables +let socket; +let socketInitialized = false; // Flag variable to track if Socket is initialized +let socketReconnectAttempts = 0; // Track reconnection attempts +let socketReconnectTimer = null; +let socketBackoffDelay = 1000; // Initial backoff delay +const maxBackoffDelay = 30000; // Maximum backoff delay (30 seconds) +let socketConnectionInProgress = false; +const maxReconnectAttempts = 20; // Maximum reconnection attempts + +// Restart countdown variables +let restartCountdownTimer = null; +let countdownStartTimer = null; +const RESTART_INTERVAL = 172000; // 172000 seconds (47.7 hours) +const COUNTDOWN_DURATION = 1200; // 20 minutes in seconds (20 * 60 = 1200) + +// Prompt template for AutoGenerate properties +const autoGeneratePromptTemplate = `Based on the experimental design and the example stimuli provided below, please complete the following tasks: +1. Identify the experimental conditions associated with each stimulus. +2. Specify the requirements that each stimulus must fulfill according to the design constraints. +3. Propose scoring dimensions that should be used to evaluate the quality of each stimulus item. + + +Experimental Design: +This experiment investigates whether individuals tend to prefer shorter words in predictive linguistic contexts. +Each stimulus item consists of a word pair and two sentence contexts: +- The two words differ in length, with the shorter being an abbreviation of the longer. They are semantically equivalent and commonly used interchangeably in everyday English (e.g., chimp/chimpanzee, math/mathematics, TV/television). +- Word pairs that form common multi-word expressions (e.g., final exam) are excluded. +- The target word is omitted at the end of both sentence contexts: +- One is a neutral context, which does not predict the target word. +- The other is a supportive context, which strongly predicts the target word. +- Both contexts are matched in length and plausibility. + +Stimuli Examples: +- Stimulus 1: +word_pair: math / mathematics +neutral_context: Susan introduced herself as someone who loved… +supportive_context: Susan was very bad at algebra, so she hated… +- Stimulus 2: +word_pair: bike / bicycle +neutral_context: Last week John finally bought himself a… +supportive_context: For commuting to work, John got a new… + +Expected Response Format: +Components: +["word_pair", "neutral_context", "supportive_context"] + +Requirements: +{ + "Synonymy": "Do the two words convey the same meaning?", + "Abbreviation": "Is the shorter word an abbreviation of the longer word?", + "Shared morpheme": "Do both words share a common morpheme?", + "Not Phrase": "Is neither word part of a multi-word phrase?", + "Real Word": "Are both words attested and used in natural English?", + "Short-Long Order": "Is the shorter word listed first in the pair?", + "Neutral Context Unpredictive": "Is the neutral context truly non-predictive of the target word?", + "Supportive Context Predictive": "Does the supportive context reliably elicit the target word?" +} + +Scoring Dimensions: +{ + "unpredictability_neutral": "Degree to which the neutral context fails to predict the target (higher = more unpredictable; from 0 to 10)", + "predictability_supportive": "Degree to which the supportive context cues the target (higher = more predictable; from 0 to 10)", + "word_pair_frequency": "Frequency with which the two words are used interchangeably(higher = more frequent; from 0 to 10)", + "word_frequency_short": "Corpus frequency of the short word(higher = more frequent; from 0 to 10)", + "word_frequency_long": "Corpus frequency of the long word(higher = more frequent; from 0 to 10)", + "neutral_context_plausibility": "Realism and coherence of the neutral context(higher = more plausible; from 0 to 10)", + "supportive_context_plausibility": "Realism and coherence of the supportive context(higher = more plausible; from 0 to 10)" +} + + +Task for New Experiment +Experimental Design: +{Experimental design} + +Example Stimuli: +{Example stimuli} + +Your Task: +Using the format illustrated above, please: +1. Identify the conditions represented in each stimulus item. +2. List the requirements for a valid stimulus pair in this experiment. +3. Propose a set of scoring dimensions to evaluate stimulus quality and conformity to design. +4. A valid stimulus must meet all the listed requirements. That is, each requirement must evaluate to TRUE. Stimuli that violate any single requirement are to be considered non-compliant with the experimental design. +5. Response me based on the expected format, without any introductary words.`; + +// Initialise WebSocket connection +function initializeSocket() { + // Prevent multiple connection attempts simultaneously + if (socketConnectionInProgress) { + console.log("WebSocket connection attempt already in progress, skipping"); + return; + } + + socketConnectionInProgress = true; + + // If Socket exists, try to close the old connection + if (socket) { + try { + // Remove all event listeners to avoid duplication + socket.off('connect'); + socket.off('connect_error'); + socket.off('disconnect'); + socket.off('reconnect_attempt'); + socket.off('reconnect'); + socket.off('reconnect_failed'); + socket.off('progress_update'); + socket.off('stimulus_update'); + socket.off('server_status'); + socket.off('error'); + + // If connected, disconnect first + if (socket.connected) { + console.log("Closing existing WebSocket connection..."); + socket.disconnect(); + } + } catch (e) { + console.error("Error closing old WebSocket connection:", e); + } + } + + // Clear all existing reconnection timers + if (socketReconnectTimer) { + clearTimeout(socketReconnectTimer); + socketReconnectTimer = null; + } + + socketInitialized = false; // Reset initialization flag + + // Get current page URL and protocol + const currentUrl = window.location.origin; + const isSecure = window.location.protocol === 'https:'; + + // Create configuration object, add query parameters including session ID + const socketOptions = { + path: '/socket.io', + transports: ['polling', 'websocket'], // Start with polling, then upgrade to websocket + reconnectionAttempts: 3, // Reduced reconnection attempts + reconnectionDelay: 2000, // Longer initial delay + reconnectionDelayMax: 10000, // Longer max delay + timeout: 10000, // Shorter timeout + forceNew: true, // Force new connection + autoConnect: true, // Auto connect + query: { 'session_id': sessionId }, // Add session ID to query parameters + upgrade: true, // Allow transport upgrade + rememberUpgrade: false, // Don't remember upgrade to avoid issues + }; + + console.log(`Connecting to Socket.IO: ${currentUrl}, Session ID: ${sessionId}`); + + try { + // Create Socket.IO connection with error handling + socket = io(currentUrl, socketOptions); + + // Add a timeout to detect connection issues + const connectionTimeout = setTimeout(() => { + if (!socket.connected) { + console.warn("Socket.IO connection timeout after 10 seconds"); + // Force connection status update + socketConnectionInProgress = false; + handleReconnect(); + } + }, 10000); + + // Connection event handling + socket.on('connect', () => { + // Clear connection timeout + clearTimeout(connectionTimeout); + + console.log('WebSocket connection successful!', socket.id); + socketInitialized = true; // Set initialization complete flag + socketReconnectAttempts = 0; // Reset reconnection counter + socketBackoffDelay = 1000; // Reset backoff delay + socketConnectionInProgress = false; // Reset connection in progress flag + + // Join session room after connection is established + setTimeout(() => { + try { + socket.emit('join_session', { session_id: sessionId }); + console.log('Sent join_session request for:', sessionId); + } catch (e) { + console.warn("Failed to send join_session:", e); + } + }, 100); + + // Add ping to verify connection is working + setTimeout(() => { + try { + socket.emit('ping', { time: Date.now() }); + } catch (e) { + console.warn("Failed to send ping after connect:", e); + } + }, 1000); + }); + + socket.on('connect_error', (error) => { + console.error('WebSocket connection error:', error); + socketInitialized = false; // Reset flag on connection error + socketConnectionInProgress = false; // Reset connection in progress flag + + // Use exponential backoff strategy for reconnection + handleReconnect(); + }); + + socket.on('error', (error) => { + console.error('WebSocket error:', error); + socketConnectionInProgress = false; // Reset connection in progress flag + }); + + socket.on('disconnect', (reason) => { + console.log(`WebSocket disconnected: ${reason}`); + socketInitialized = false; // Reset flag on disconnection + socketConnectionInProgress = false; // Reset connection in progress flag + + // If disconnection reason is not client-initiated, try to reconnect + if (reason !== 'io client disconnect' && reason !== 'io server disconnect') { + // Use exponential backoff strategy for reconnection + handleReconnect(); + } + }); + + socket.on('reconnect_attempt', (attemptNumber) => { + console.log(`Attempting reconnection (${attemptNumber})...`); + socketReconnectAttempts = attemptNumber; + }); + + socket.on('reconnect', (attemptNumber) => { + console.log(`Reconnection successful, attempts: ${attemptNumber}`); + socketInitialized = true; // Restore flag on successful reconnection + socketReconnectAttempts = 0; // Reset reconnection counter + socketBackoffDelay = 1000; // Reset backoff delay + }); + + socket.on('reconnect_failed', () => { + console.error('WebSocket reconnection failed after maximum attempts'); + socketInitialized = false; // Reset flag on reconnection failure + socketConnectionInProgress = false; // Reset connection in progress flag + + // Use our own reconnection strategy + handleReconnect(); + }); + + // Listen for progress_update event, update progress bar + socket.on('progress_update', (data) => { + console.log('Received progress update:', data); + if (data.session_id === sessionId) { + updateProgress(data.progress); + } + }); + + // Listen for stimulus_update event, update logs + socket.on('stimulus_update', (data) => { + console.log('Received stimulus update:', data); + if (data.session_id === sessionId) { + handleLogMessage(data.type, data.message); + } + }); + + // Listen for server_status event + socket.on('server_status', (data) => { + console.log('Received server status:', data); + // Handle server status messages + }); + + // Add ping/pong handler for connection monitoring + socket.on('pong', (data) => { + const roundTripTime = Date.now() - (data.time || 0); + console.log(`Received pong response, round-trip time: ${roundTripTime}ms`); + + // Schedule next ping to keep connection alive + setTimeout(() => { + if (socket && socket.connected) { + try { + socket.emit('ping', { time: Date.now() }); + } catch (e) { + console.warn("Failed to send ping:", e); + } + } + }, 30000); // Send ping every 30 seconds + }); + + } catch (e) { + console.error("Error creating WebSocket connection:", e); + socketConnectionInProgress = false; // Reset connection in progress flag + + // Use exponential backoff strategy for reconnection + handleReconnect(); + } +} + +// Add exponential backoff reconnection handler +function handleReconnect() { + // Clear any existing reconnection timer + if (socketReconnectTimer) { + clearTimeout(socketReconnectTimer); + socketReconnectTimer = null; + } + + // Increment reconnection attempts counter + socketReconnectAttempts++; + + // Check if we've reached the maximum number of attempts + if (socketReconnectAttempts > maxReconnectAttempts) { + console.error(`Maximum reconnection attempts (${maxReconnectAttempts}) reached, stopping`); + // Show a user-friendly message in the UI + appendLogMessage(generatorLog, "WebSocket connection lost. Please refresh the page.", "error"); + appendLogMessage(validatorLog, "WebSocket connection lost. Please refresh the page.", "error"); + appendLogMessage(scorerLog, "WebSocket connection lost. Please refresh the page.", "error"); + return; + } + + // Calculate exponential backoff delay with jitter + // Add random jitter (±20%) to prevent reconnection storms + const jitterFactor = 0.8 + (Math.random() * 0.4); // Random value between 0.8 and 1.2 + const baseDelay = socketBackoffDelay * Math.pow(1.5, socketReconnectAttempts - 1); + const delay = Math.min(baseDelay * jitterFactor, maxBackoffDelay); + + console.log(`Scheduling reconnection attempt ${socketReconnectAttempts} in ${Math.round(delay)}ms`); + + // Set a timer for the next reconnection attempt + socketReconnectTimer = setTimeout(() => { + console.log(`Executing reconnection attempt ${socketReconnectAttempts}`); + + // Reset connection flag to allow a new connection attempt + socketConnectionInProgress = false; + + // Try to reconnect + try { + // For later reconnection attempts, try to recreate the socket from scratch + if (socketReconnectAttempts > 2 && socket) { + try { + // Force close and cleanup + socket.close(); + socket.disconnect(); + socket = null; + } catch (e) { + console.warn("Error during socket cleanup:", e); + } + } + + // Initialize a new socket connection + initializeSocket(); + } catch (e) { + console.error("Error during reconnection:", e); + // If reconnection fails, schedule another attempt + socketConnectionInProgress = false; + handleReconnect(); + } + }, delay); +} + +// Check WebSocket connection status +function checkSocketConnection() { + if (!socket || !socket.connected) { + console.log("WebSocket not connected, reinitializing..."); + socketInitialized = false; + + // Only initialize if no connection attempt is in progress + if (!socketConnectionInProgress) { + initializeSocket(); + } + return false; + } + return true; +} + +// Ensure WebSocket connection and wait for connection establishment before executing callback +function ensureSocketConnection(callback, maxWaitTime = 5000) { + // If WebSocket is already connected, execute callback directly + if (socket && socket.connected) { + console.log("WebSocket already connected, executing operation"); + callback(); + return; + } + + console.log("WebSocket not connected, attempting reconnection..."); + + // Reset retry counters + socketReconnectAttempts = 0; + socketBackoffDelay = 1000; + + // Set retry counter + let retryCount = 0; + const maxRetries = 3; + + // Create retry function + function attemptConnection() { + retryCount++; + console.log(`Connection attempt ${retryCount}/${maxRetries}...`); + + // Reinitialize WebSocket connection + socketConnectionInProgress = false; // Ensure new connection can be made + initializeSocket(); + + // Wait for connection establishment timeout + const timeoutId = setTimeout(() => { + console.warn(`WebSocket connection timeout (attempt ${retryCount}/${maxRetries})`); + + // If there are remaining retries, continue attempting + if (retryCount < maxRetries) { + attemptConnection(); + } else { + console.error("WebSocket connection failed, attempting to continue operation"); + // Try to continue execution to maintain user experience + callback(); + } + }, maxWaitTime); + + // Listen for successful connection event + socket.once('connect', () => { + clearTimeout(timeoutId); // Clear timeout timer + console.log("WebSocket connection successful"); + + // Wait for server_status confirmation to ensure room join success + const roomConfirmTimeoutId = setTimeout(() => { + console.warn("No room confirmation received, continuing operation"); + // Brief delay to ensure event listeners are registered + setTimeout(callback, 200); + }, 1000); + + // Add one-time listener for server_status message + socket.once('server_status', (data) => { + clearTimeout(roomConfirmTimeoutId); + console.log("Received server status confirmation:", data); + + if (data.room_joined) { + console.log("Successfully joined room:", data.session_id); + } else { + console.warn("Failed to join room or no room information provided"); + } + + // Brief delay to ensure event listeners are registered + setTimeout(callback, 200); + }); + }); + } + + // Start first connection attempt + attemptConnection(); +} + +// Handle log messages +function handleLogMessage(type, message) { + switch (type) { + case 'generator': + appendLogMessage(generatorLog, message); + break; + case 'validator': + appendLogMessage(validatorLog, message); + break; + case 'scorer': + appendLogMessage(scorerLog, message); + break; + case 'all': + // Send to all log panels + appendLogMessage(generatorLog, message); + appendLogMessage(validatorLog, message); + appendLogMessage(scorerLog, message); + break; + case 'setup': + // Send message to Console only + console.log('Setup:', message); + break; + case 'error': + // Send error messages to all panels with error style + appendLogMessage(generatorLog, message, 'error'); + appendLogMessage(validatorLog, message, 'error'); + appendLogMessage(scorerLog, message, 'error'); + break; + default: + console.log('Unknown message type:', type, message); + } +} + +// Clear log function +function clearLog(logId) { + const logElement = document.getElementById(logId); + if (logElement) { + logElement.innerHTML = ''; + } +} + +// Add log message to panel +function appendLogMessage(logElement, message, className = '') { + // Create message container + const logMessage = document.createElement('div'); + + // Determine message type based on content and className + let messageType = className || 'info'; + const lowerMessage = message.toLowerCase(); + + // Error messages (red) + if (lowerMessage.includes('error') || + lowerMessage.includes('failed') || + lowerMessage.includes('parsing error') || + lowerMessage.includes('invalid response') || + lowerMessage.includes('api error') || + lowerMessage.includes('timeout')) { + messageType = 'error'; + } + // Success messages (green) + else if (lowerMessage.includes('passed') || + lowerMessage.includes('completed') || + lowerMessage.includes('success') || + lowerMessage.includes('all criteria passed') || + lowerMessage.includes('all validations passed') || + lowerMessage.includes('individual scoring completed') || + lowerMessage.includes('individual validation completed')) { + messageType = 'success'; + } + // Warning messages (orange) + else if (lowerMessage.includes('warning') || + lowerMessage.includes('early rejection') || + lowerMessage.includes('regenerating') || + lowerMessage.includes('failed validation') || + lowerMessage.includes('stopping validation') || + lowerMessage.includes('detected repeated stimulus')) { + messageType = 'warning'; + } + // Progress messages (purple) - for step-by-step processes + else if (lowerMessage.includes('evaluating aspect') || + lowerMessage.includes('validating criterion') || + lowerMessage.includes('checking') && lowerMessage.includes('criteria') || + (lowerMessage.includes('aspect') && lowerMessage.includes(':') && (lowerMessage.includes('/') || lowerMessage.includes('passed') || lowerMessage.includes('failed'))) || + (lowerMessage.includes('criterion') && lowerMessage.includes(':') && (lowerMessage.includes('passed') || lowerMessage.includes('failed')))) { + messageType = 'progress'; + } + // Info messages (blue) - for general information + else if (lowerMessage.includes('using individual') || + lowerMessage.includes('using batch') || + lowerMessage.includes('mode') || + lowerMessage.includes('properties:') || + lowerMessage.includes('starting') || + lowerMessage.includes('output:')) { + messageType = 'info'; + } + + logMessage.className = `log-message ${messageType}`; + + // Add timestamp + const timestamp = new Date().toLocaleTimeString(); + const timestampElement = document.createElement('div'); + timestampElement.className = 'log-timestamp'; + timestampElement.textContent = timestamp; + + // Create text container + const textElement = document.createElement('div'); + textElement.className = 'log-text'; + + // Format message content with icons + let messageIcon = ''; + if (messageType === 'success') { + messageIcon = ''; + } else if (messageType === 'error') { + messageIcon = ''; + } else if (messageType === 'warning') { + messageIcon = ''; + } else if (messageType === 'progress') { + messageIcon = ''; + } else if (messageType === 'info') { + messageIcon = ''; + } + + // Format message content + if (message.includes('=== No.')) { + // Use special style for round information + textElement.innerHTML = `${messageIcon}${message}`; + } else if (message.includes('Output:')) { + // Agent output, try to format JSON + try { + const parts = message.split('Output:'); + const prefix = parts[0]; + const jsonText = parts[1].trim(); + + // Try to format JSON (horizontal format with colors) + if (jsonText.startsWith('{') && jsonText.endsWith('}')) { + const coloredJson = formatJsonHorizontal(jsonText); + textElement.innerHTML = `${messageIcon}${prefix}Output: ${coloredJson}`; + } else { + textElement.innerHTML = `${messageIcon}${message}`; + } + } catch (e) { + textElement.innerHTML = `${messageIcon}${message}`; + } + } else { + // General message + textElement.innerHTML = `${messageIcon}${message}`; + } + + // Append timestamp and text to message container + logMessage.appendChild(timestampElement); + logMessage.appendChild(textElement); + + // Add to log panel + logElement.appendChild(logMessage); + + // Scroll to bottom + logElement.scrollTop = logElement.scrollHeight; +} + +// Format JSON string (multi-line version) +function formatJson(jsonString) { + try { + const obj = JSON.parse(jsonString); + return syntaxHighlight(obj); + } catch (e) { + return jsonString; + } +} + +// Format JSON string (compact single-line version) +function formatJsonCompact(jsonString) { + try { + const obj = JSON.parse(jsonString); + return syntaxHighlightCompact(obj); + } catch (e) { + return jsonString; + } +} + +// JSON syntax highlighting (multi-line) +function syntaxHighlight(json) { + if (typeof json != 'string') { + json = JSON.stringify(json, undefined, 2); + } + + json = json.replace(/&/g, '&').replace(//g, '>'); + return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { + let cls = 'json-number'; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'json-key'; + } else { + cls = 'json-string'; + } + } else if (/true|false/.test(match)) { + cls = 'json-boolean'; + } else if (/null/.test(match)) { + cls = 'json-null'; + } + return '' + match + ''; + }); +} + +// JSON syntax highlighting (compact single-line) +function syntaxHighlightCompact(json) { + if (typeof json != 'string') { + json = JSON.stringify(json); // No indentation for compact display + } + + json = json.replace(/&/g, '&').replace(//g, '>'); + return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { + let cls = 'json-number'; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'json-key'; + } else { + cls = 'json-string'; + } + } else if (/true|false/.test(match)) { + cls = 'json-boolean'; + } else if (/null/.test(match)) { + cls = 'json-null'; + } + return '' + match + ''; + }); +} + +// Format JSON string (horizontal format with colors) +function formatJsonHorizontal(jsonString) { + try { + const obj = JSON.parse(jsonString); + const entries = Object.entries(obj); + + const formattedPairs = entries.map(([key, value]) => { + let formattedValue; + + if (typeof value === 'string') { + formattedValue = `"${value}"`; + } else if (typeof value === 'boolean') { + formattedValue = `${value}`; + } else if (typeof value === 'number') { + formattedValue = `${value}`; + } else if (value === null) { + formattedValue = `null`; + } else { + formattedValue = `"${JSON.stringify(value)}"`; + } + + return `"${key}": ${formattedValue}`; + }); + + return `{${formattedPairs.join(', ')}}`; + + } catch (e) { + return jsonString; + } +} + +// Clear log display area +function clearLogs() { + generatorLog.innerHTML = ''; + validatorLog.innerHTML = ''; + scorerLog.innerHTML = ''; +} + +// Get API URL prefix with session ID +function getApiUrl(endpoint) { + return `/${sessionId}/${endpoint}`; +} + +// Page initialization function +function initializePage() { + // Ensure Stop button is disabled by default + stopButton.disabled = true; + + // Ensure all other buttons and input elements are enabled + generateButton.disabled = false; + clearButton.disabled = false; + addAgent2Button.disabled = false; + addAgent3Button.disabled = false; + + // Determine API key input availability based on default model selection + handleModelChange(); + + // Ensure progress bar is at 0% + updateProgress(0); + + // Initialize WebSocket connection + initializeSocket(); + + // Start periodic connection check + periodicSocketCheck(); + + // Set up UI protection mechanism + setupUIProtection(); + + // Initialize restart countdown system + initializeRestartCountdown(); + + // Display session ID in console for debugging + console.log(`Initialized page with session ID: ${sessionId}`); +} + +// Handle model switching function +function handleModelChange() { + const modelSelect = document.getElementById('model_choice'); + const customModelConfig = document.getElementById('custom_model_config'); + const apiKeyInput = document.getElementById('api_key'); + + if (modelSelect.value === 'custom') { + customModelConfig.style.display = 'block'; + apiKeyInput.disabled = false; + apiKeyInput.required = true; + apiKeyInput.placeholder = 'Enter your API Key'; + } else if (modelSelect.value === 'GPT-4o') { + customModelConfig.style.display = 'none'; + apiKeyInput.disabled = false; + apiKeyInput.required = true; + apiKeyInput.placeholder = 'Enter your OpenAI API Key'; + } else { + customModelConfig.style.display = 'none'; + apiKeyInput.disabled = false; + apiKeyInput.required = true; + apiKeyInput.placeholder = 'API Key required'; + } +} + +// Initial setup: Add event listeners for the first Item's buttons +document.querySelector('.add-component-btn').addEventListener('click', addComponentToAllItems); +document.querySelector('.delete-component-btn').addEventListener('click', deleteComponentFromAllItems); +document.querySelector('.add-item-btn').addEventListener('click', addNewItem); + +// Add model selection listener +modelChoice.addEventListener('change', handleModelChange); + +// Initialize on page load +document.addEventListener('DOMContentLoaded', initializePage); + +// Initialize model selection state +handleModelChange(); + +// Add page unload event to cleanup timers +window.addEventListener('beforeunload', () => { + cleanupCountdownTimers(); +}); + +// Add page visibility change event to handle tab switching +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + // Page became visible again, reinitialize countdown if needed + console.log('Page became visible, checking countdown status'); + // Don't reinitialize automatically to avoid conflicts + } +}); + +// Validate whether AutoGenerate required inputs are filled correctly +function validateAutoGenerateInputs() { + const selectedModel = modelChoice.value; + const apiKey = document.getElementById('api_key').value.trim(); + const experimentDesign = document.getElementById('experiment_design').value.trim(); + + // Check if a model is selected + if (!selectedModel) { + alert('Please select a model!'); + return false; + } + + // If GPT-4o is selected, check API Key + if (selectedModel === 'GPT-4o' && !apiKey) { + alert('OpenAI API Key cannot be empty when using GPT-4o!'); + return false; + } + + // If custom model is selected, check API Key, URL, and model name + if (selectedModel === 'custom') { + const apiUrl = document.getElementById('custom_api_url').value.trim(); // Fixed field ID + const modelName = document.getElementById('custom_model_name').value.trim(); // Add model name validation + if (!apiKey) { + alert('API Key cannot be empty when using custom model!'); + return false; + } + if (!modelName) { // Add model name validation + alert('Model Name cannot be empty when using custom model!'); + return false; + } + if (!apiUrl) { + alert('API URL cannot be empty when using custom model!'); + return false; + } + + // Validate custom parameters if provided + const customParams = document.getElementById('custom_params').value.trim(); + if (customParams) { + try { + JSON.parse(customParams); + } catch (e) { + alert('Invalid JSON in custom parameters!'); + return false; + } + } + } + + // Check experimental design + if (!experimentDesign) { + alert('Stimulus design cannot be empty!'); + return false; + } + + // Check input in Item tables + const itemContainers = document.querySelectorAll('.item-container'); + if (itemContainers.length === 0) { + alert('At least one example stimulus item is required!'); + return false; + } + + // Check if all example items are filled correctly + for (let i = 0; i < itemContainers.length; i++) { + const tbody = itemContainers[i].querySelector('.stimuli-table tbody'); + const rows = tbody.getElementsByTagName('tr'); + + if (rows.length === 0) { + alert(`No component rows in Example Item ${i + 1}!`); + return false; + } + + for (let j = 0; j < rows.length; j++) { + const typeCell = rows[j].querySelector('.type-column input'); + const contentCell = rows[j].querySelector('.content-column input'); + + if (typeCell && contentCell) { + const typeValue = typeCell.value.trim(); + const contentValue = contentCell.value.trim(); + + if (!typeValue || !contentValue) { + alert(`All "Components" and "Content" fields in Item ${i + 1} must be filled!`); + return false; + } + } + } + } + + return true; +} + +// Collect data from Example Stimuli tables +function collectExampleStimuli() { + const stimuli = []; + const itemContainers = document.querySelectorAll('.item-container'); + + itemContainers.forEach((itemContainer, index) => { + const tbody = itemContainer.querySelector('.stimuli-table tbody'); + const rows = tbody.getElementsByTagName('tr'); + const itemData = {}; + + for (let i = 0; i < rows.length; i++) { + const typeCell = rows[i].querySelector('.type-column input'); + const contentCell = rows[i].querySelector('.content-column input'); + + if (typeCell && contentCell) { + const type = typeCell.value.trim(); + const content = contentCell.value.trim(); + + if (type && content) { + itemData[type] = content; + } + } + } + + // Only add to stimuli array when itemData is not empty + if (Object.keys(itemData).length > 0) { + stimuli.push(itemData); + } + }); + + return stimuli; +} + +// Add new component row to all tables +function addComponentToAllItems() { + const tables = document.querySelectorAll('.stimuli-table tbody'); + tables.forEach(tbody => { + const newRow = document.createElement('tr'); + newRow.innerHTML = ` + + + `; + tbody.appendChild(newRow); + }); +} + +// Delete last component row from all tables +function deleteComponentFromAllItems() { + const tables = document.querySelectorAll('.stimuli-table tbody'); + tables.forEach(tbody => { + const rows = tbody.getElementsByTagName('tr'); + if (rows.length > 1) { // Ensure at least one row is kept + tbody.removeChild(rows[rows.length - 1]); + } + }); +} + +// Add new Item +function addNewItem() { + // Get current newest Item number and calculate new Item number + const itemContainers = document.querySelectorAll('.item-container'); + const lastItem = itemContainers[itemContainers.length - 1]; + const lastItemId = lastItem.id; + const lastItemNumber = parseInt(lastItemId.split('-')[1]); + const newItemNumber = lastItemNumber + 1; + + // Get current table row count + const lastItemTable = lastItem.querySelector('.stimuli-table tbody'); + const rowCount = lastItemTable.rows.length; + + // Create new Item container + const newItem = document.createElement('div'); + newItem.className = 'item-container'; + newItem.id = `item-${newItemNumber}`; + + // Create Item title + const itemTitle = document.createElement('div'); + itemTitle.className = 'item-title'; + itemTitle.textContent = `Item ${newItemNumber}`; + newItem.appendChild(itemTitle); + + // Create table + const table = document.createElement('table'); + table.className = 'stimuli-table'; + + // Add table header + const thead = document.createElement('thead'); + thead.innerHTML = ` + + Components + Content + + `; + table.appendChild(thead); + + // Add table content + const tbody = document.createElement('tbody'); + for (let i = 0; i < rowCount; i++) { + const row = document.createElement('tr'); + row.innerHTML = ` + + + `; + tbody.appendChild(row); + } + table.appendChild(tbody); + newItem.appendChild(table); + + // Add buttons + const buttonDiv = document.createElement('div'); + buttonDiv.className = 'item-buttons-row'; + buttonDiv.innerHTML = ` +
+ + +
+
+ + +
+ `; + newItem.appendChild(buttonDiv); + + // Remove buttons from previous Item container + lastItem.querySelector('.item-buttons-row').remove(); + + // Add new Item to container + itemsContainer.appendChild(newItem); + + // Add event listeners for new buttons + newItem.querySelector('.add-component-btn').addEventListener('click', addComponentToAllItems); + newItem.querySelector('.delete-component-btn').addEventListener('click', deleteComponentFromAllItems); + newItem.querySelector('.add-item-btn').addEventListener('click', addNewItem); + newItem.querySelector('.delete-item-btn').addEventListener('click', function () { + deleteItem(newItem.id); + }); +} + +// Delete Item +function deleteItem(itemId) { + const itemToDelete = document.getElementById(itemId); + const itemContainers = document.querySelectorAll('.item-container'); + + // If there's only one Item, don't perform delete operation + if (itemContainers.length <= 1) { + return; + } + + // Get position of Item to be deleted + let itemIndex = -1; + for (let i = 0; i < itemContainers.length; i++) { + if (itemContainers[i].id === itemId) { + itemIndex = i; + break; + } + } + + // If it's the last Item, need to add buttons to previous Item + if (itemIndex === itemContainers.length - 1) { + const previousItem = itemContainers[itemIndex - 1]; + const buttonDiv = document.createElement('div'); + buttonDiv.className = 'item-buttons-row'; + + // If it's Item 1, no Delete item button needed + if (previousItem.id === 'item-1') { + buttonDiv.innerHTML = ` +
+ + +
+
+ +
+ `; + } else { + buttonDiv.innerHTML = ` +
+ + +
+
+ + +
+ `; + } + + previousItem.appendChild(buttonDiv); + + // Add event listeners for new buttons + previousItem.querySelector('.add-component-btn').addEventListener('click', addComponentToAllItems); + previousItem.querySelector('.delete-component-btn').addEventListener('click', deleteComponentFromAllItems); + previousItem.querySelector('.add-item-btn').addEventListener('click', addNewItem); + + const deleteItemBtn = previousItem.querySelector('.delete-item-btn'); + if (deleteItemBtn) { + deleteItemBtn.addEventListener('click', function () { + deleteItem(previousItem.id); + }); + } + } + + // Delete Item + itemToDelete.remove(); +} + +// Collect data from all Item tables +function collectItemsData() { + const stimuli = []; + const itemContainers = document.querySelectorAll('.item-container'); + + itemContainers.forEach(itemContainer => { + const tbody = itemContainer.querySelector('.stimuli-table tbody'); + const rows = tbody.getElementsByTagName('tr'); + const itemData = {}; + + for (let i = 0; i < rows.length; i++) { + const typeCell = rows[i].querySelector('.type-column input'); + const contentCell = rows[i].querySelector('.content-column input'); + + if (typeCell && contentCell) { + const type = typeCell.value.trim(); + const content = contentCell.value.trim(); + + if (type && content) { + itemData[type] = content; + } + } + } + + // Only add to stimuli array when itemData is not empty + if (Object.keys(itemData).length > 0) { + stimuli.push(itemData); + } + }); + + return stimuli; +} + + +// This event listener was removed since we now have individual delete buttons for each row + +// Show generating status text +function showGeneratingStatus() { + generationStatus.textContent = "Generating..."; + generationStatus.className = "generation-status generating"; + generationStatus.style.display = "inline-block"; +} + +// Show checking status text +function showCheckingStatus() { + generationStatus.textContent = "Scoring & Checking status & Creating file..."; + generationStatus.className = "generation-status checking"; + generationStatus.style.display = "inline-block"; +} + +// Hide status text +function hideGenerationStatus() { + generationStatus.style.display = "none"; + generationStatus.textContent = ""; +} + +// Update progress bar +function updateProgress(progress) { + console.log(`Updating progress bar to ${progress}%`); + + // Ensure progress value is a number + if (typeof progress !== 'number') { + try { + progress = parseFloat(progress); + } catch (e) { + console.error("Invalid progress value:", progress); + return; + } + } + + // Progress value should be between 0-100 + progress = Math.max(0, Math.min(100, progress)); + + // Round to integer + const roundedProgress = Math.round(progress); + + // Get current progress + let currentProgressText = progressBar.style.width || '0%'; + let currentProgress = parseInt(currentProgressText.replace('%', ''), 10) || 0; + + // For special cases: + // 1. If current progress is already 100%, no updates are accepted (unless explicitly reset to 0%) + if (currentProgress >= 100 && roundedProgress > 0) { + console.log(`Progress already at 100%, ignoring update to ${roundedProgress}%`); + return; + } + + // 2. If the received progress is less than the current progress and the difference is more than 10%, it may be a new generation process + // At this time, we accept the smaller progress value + const progressDifference = currentProgress - roundedProgress; + if (progressDifference > 10 && roundedProgress <= 20) { + console.log(`Accepting progress ${roundedProgress}% as start of new generation`); + currentProgress = 0; // Reset progress + } + // 3. Otherwise, if the received progress is less than the current progress, ignore it (unless it's 0% to indicate reset) + else if (roundedProgress < currentProgress && roundedProgress > 0) { + console.log(`Ignoring backward progress update: ${roundedProgress}% < ${currentProgress}%`); + return; + } + + // Update progress display + progressBar.style.width = `${roundedProgress}%`; + document.getElementById('progress_percentage').textContent = `${roundedProgress}%`; + + console.log(`Progress bar updated to ${roundedProgress}%`); + + // When progress reaches 100%, switch status text + if (roundedProgress >= 100) { + showCheckingStatus(); + } + + // Enable/disable buttons + if (roundedProgress > 0 && roundedProgress < 100) { + // In progress + generateButton.disabled = true; + stopButton.disabled = false; + } else if (roundedProgress >= 100) { + // Completed + generateButton.disabled = false; + stopButton.disabled = true; + } +} + +function resetUI() { + // Hide generating status text + hideGenerationStatus(); + + // Enable all buttons + generateButton.disabled = false; + clearButton.disabled = false; + stopButton.disabled = true; // Stop button remains disabled + + // Enable all table-related buttons + addAgent2Button.disabled = false; + addAgent3Button.disabled = false; + + // Enable Add/Delete buttons in all tables + document.querySelectorAll('.add-component-btn, .delete-component-btn, .add-item-btn, .delete-item-btn').forEach(btn => { + btn.disabled = false; + }); + + // Enable model selection dropdown + document.getElementById('model_choice').disabled = false; + + // Determine whether API Key input box is available based on the currently selected model + const selectedModel = document.getElementById('model_choice').value; + if (selectedModel === 'GPT-4o') { + document.getElementById('api_key').disabled = false; + } else { + document.getElementById('api_key').disabled = true; + } + + // Enable all other input boxes and text areas + document.getElementById('iteration').disabled = false; + document.getElementById('experiment_design').disabled = false; + + // Enable all input boxes in all tables + document.querySelectorAll('input[type="text"], input[type="number"], textarea').forEach(input => { + if (input.id !== 'api_key') { // API Key input box is excluded + input.disabled = false; + } + }); + + // Reset progress bar + updateProgress(0); +} + +function validateInputs() { + const selectedModel = modelChoice.value; + const apiKey = document.getElementById('api_key').value.trim(); + const iteration = document.getElementById('iteration').value.trim(); + const experimentDesign = document.getElementById('experiment_design').value.trim(); + + // Check if a model is selected + if (!selectedModel) { + alert('Please choose a model!'); + return false; + } + + // If GPT-4o is selected, check API Key + if (selectedModel === 'GPT-4o' && !apiKey) { + alert('OpenAI API Key cannot be empty when using GPT-4o!'); + return false; + } + + // If custom model is selected, check API Key and URL + if (selectedModel === 'custom') { + const apiUrl = document.getElementById('custom_api_url').value.trim(); // Fixed field ID + const modelName = document.getElementById('custom_model_name').value.trim(); // Add model name validation + if (!apiKey) { + alert('API Key cannot be empty when using custom model!'); + return false; + } + if (!modelName) { // Add model name validation + alert('Model Name cannot be empty when using custom model!'); + return false; + } + if (!apiUrl) { + alert('API URL cannot be empty when using custom model!'); + return false; + } + + // Validate custom parameters if provided + const customParams = document.getElementById('custom_params').value.trim(); + if (customParams) { + try { + JSON.parse(customParams); + } catch (e) { + alert('Invalid JSON in custom parameters!'); + return false; + } + } + } + + // Check inputs in Item tables + const itemContainers = document.querySelectorAll('.item-container'); + for (let i = 0; i < itemContainers.length; i++) { + const tbody = itemContainers[i].querySelector('.stimuli-table tbody'); + const rows = tbody.getElementsByTagName('tr'); + + for (let j = 0; j < rows.length; j++) { + const typeCell = rows[j].querySelector('.type-column input'); + const contentCell = rows[j].querySelector('.content-column input'); + + if (typeCell && contentCell) { + const typeValue = typeCell.value.trim(); + const contentValue = contentCell.value.trim(); + + if (!typeValue || !contentValue) { + alert(`All Components and Content fields in the "Item ${i + 1}" table must be filled!`); + return false; + } + } + } + } + + // Check Experiment Design + if (!experimentDesign) { + alert('Stimulus design cannot be empty!'); + return false; + } + + const agent2Rows = document.getElementById('agent2PropertiesTable').getElementsByTagName('tr'); + for (let i = 1; i < agent2Rows.length; i++) { // Skip header + const cells = agent2Rows[i].getElementsByTagName('input'); + if (cells.length === 2) { + const propertyValue = cells[0].value.trim(); + const descriptionValue = cells[1].value.trim(); + if (!propertyValue || !descriptionValue) { + alert('All fields in the "Validator" table must be filled!'); + return false; + } + } + } + + // Check agent3PropertiesTable + const agent3Rows = document.getElementById('agent3PropertiesTable').getElementsByTagName('tr'); + for (let i = 1; i < agent3Rows.length; i++) { // Skip header + const cells = agent3Rows[i].getElementsByTagName('input'); + if (cells.length === 4) { + const propertyValue = cells[0].value.trim(); + const descriptionValue = cells[1].value.trim(); + const minValue = cells[2].value.trim(); + const maxValue = cells[3].value.trim(); + + if (!propertyValue || !descriptionValue || !minValue || !maxValue) { + alert('All fields in the "Scorer" table must be filled!'); + return false; + } + + // Ensure Minimum and Maximum are integers + if (!/^\d+$/.test(minValue) || !/^\d+$/.test(maxValue)) { + alert('Min and Max scores must be non-negative integers (e.g., 0, 1, 2, 3...)'); + return false; + } + + const minInt = parseInt(minValue, 10); + const maxInt = parseInt(maxValue, 10); + if (maxInt <= minInt) { + alert('Max score must be greater than Min score!'); + return false; + } + } + } + + // Check if Iteration is a positive integer + if (!/^\d+$/.test(iteration) || parseInt(iteration, 10) <= 0) { + alert("'The number of items' must be a positive integer!"); + return false; + } + + return true; // If all checks pass, return true +} + +// Modify generateButton click handler +generateButton.addEventListener('click', () => { + if (!validateInputs()) { + return; // If frontend input checks fail, terminate "Generate Stimulus" related operations + } + + // Ensure WebSocket connection + ensureSocketConnection(startGeneration); +}); + +// Extract generation process as a standalone function +function startGeneration() { + updateProgress(0); + + // Show "Generating..." status text + showGeneratingStatus(); + + // Clear log display area + clearLogs(); + + // Ensure WebSocket connection status is normal before generation starts + checkSocketConnection(); + + // Disable all buttons, except Stop button + generateButton.disabled = true; + clearButton.disabled = true; + stopButton.disabled = false; + + // Disable all table-related buttons + addAgent2Button.disabled = true; + addAgent3Button.disabled = true; + + // Disable Add/Delete buttons in all tables + document.querySelectorAll('.add-component-btn, .delete-component-btn, .add-item-btn, .delete-item-btn').forEach(btn => { + btn.disabled = true; + }); + + // Disable model selection dropdown + document.getElementById('model_choice').disabled = true; + + // Disable all input boxes and text areas + document.getElementById('api_key').disabled = true; + document.getElementById('iteration').disabled = true; + document.getElementById('experiment_design').disabled = true; + + // Disable all input boxes in all tables + document.querySelectorAll('input[type="text"], input[type="number"], textarea').forEach(input => { + input.disabled = true; + }); + + // Collect table data + const stimuli = collectItemsData(); + const previousStimuli = JSON.stringify(stimuli); // Convert to JSON string + + // Modify the logic to get agent1Properties + // Get data from Components column of the first Item table + const agent1Properties = {}; + // Find the first Item table + const firstItemTable = document.querySelector('#item-1 .stimuli-table'); + if (firstItemTable) { + // Get all rows (skip header) + const componentRows = Array.from(firstItemTable.querySelectorAll('tbody tr')); + componentRows.forEach(row => { + // Get input value from Components column + const componentInput = row.querySelector('td.type-column input'); + if (componentInput && componentInput.value.trim()) { + agent1Properties[componentInput.value.trim()] = { type: 'string' }; + } + }); + } + + // Get agent2_properties + const agent2Rows = Array.from(agent2Table.rows).slice(1); + let agent2Properties = {}; + agent2Rows.forEach(row => { + const propertyName = row.cells[0].querySelector("input").value.trim(); + const propertyDesc = row.cells[1].querySelector("input").value.trim(); + if (propertyName && propertyDesc) { + agent2Properties[propertyName] = { + "type": "boolean", + "description": propertyDesc + }; + } + }); + + + + // Get agent3_properties + const agent3PropertiesTable = document.getElementById('agent3PropertiesTable'); + const agent3Rows = Array.from(agent3PropertiesTable.rows).slice(1); // Skip header + let agent3Properties = {}; + + agent3Rows.forEach(row => { + let property = row.cells[0].querySelector('input').value.trim(); + let description = row.cells[1].querySelector('input').value.trim(); + let min = row.cells[2].querySelector('input').value.trim(); + let max = row.cells[3].querySelector('input').value.trim(); + + if (property && description && min && max) { + agent3Properties[property] = { + "type": "integer", + "description": description, + "minimum": parseInt(min, 10), + "maximum": parseInt(max, 10) + }; + } + }); + + const settings = { + agent1Properties: JSON.stringify(agent1Properties), + agent2Properties: JSON.stringify(agent2Properties), + agent3Properties: JSON.stringify(agent3Properties), + apiKey: document.getElementById('api_key').value, + modelChoice: document.getElementById('model_choice').value, + experimentDesign: document.getElementById('experiment_design').value, + previousStimuli: previousStimuli, + iteration: parseInt(document.getElementById('iteration').value), + agent2IndividualValidation: document.getElementById('agent2_individual_validation').checked, + agent3IndividualScoring: document.getElementById('agent3_individual_scoring').checked + }; + + // Add custom model parameters if custom model is selected + if (settings.modelChoice === 'custom') { + settings.apiUrl = document.getElementById('custom_api_url').value.trim(); + settings.modelName = document.getElementById('custom_model_name').value.trim(); // Fixed field ID + const customParams = document.getElementById('custom_params').value.trim(); + if (customParams) { + try { + settings.params = JSON.parse(customParams); + } catch (e) { + alert('Invalid JSON in custom parameters!'); + resetUI(); + return; + } + } + } + + // Add request timeout control + const fetchWithTimeout = (url, options, timeout = 10000) => { + return Promise.race([ + fetch(url, options), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Request timed out')), timeout) + ) + ]); + }; + + // Add retry counter + let fetchRetryCount = 0; + const maxFetchRetries = 2; + + // Function to handle fetch requests + function attemptFetch() { + fetchRetryCount++; + console.log(`Fetch attempt ${fetchRetryCount}/${maxFetchRetries + 1}...`); + + fetchWithTimeout(getApiUrl('generate_stimulus'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }, 15000) + .then(response => { + if (!response.ok) { + throw new Error(`Failed to start stimulus generation: ${response.status} ${response.statusText.trim()}.`); + } + return response.json(); + }) + .then(data => { + console.log('Stimulus generation started, server response:', data); + alert('Stimulus generation started!'); + + // Start checking generation status + checkGenerationStatus(); + }) + .catch(error => { + console.error('Error starting stimulus generation:', error); + + // If there are retries left, continue trying + if (fetchRetryCount <= maxFetchRetries) { + console.log(`Request failed, trying again...`); + setTimeout(attemptFetch, 2000); // Wait 2 seconds before retrying + } else { + resetUI(); + alert(`Failed to start stimulus generation: ${error.message}. Please try again later.`); + } + }); + } + + // Start first request attempt + attemptFetch(); +} + +function checkGenerationStatus() { + // Ensure WebSocket connection is active, if not, try to reconnect + if (!socket || !socket.connected) { + console.log("Detected WebSocket connection disconnected, trying to reconnect"); + initializeSocket(); + } + + fetch(getApiUrl('generation_status')) + .then(response => { + if (!response.ok) { + if (response.status === 400) { + // Maybe session expired, wait 2 seconds before retrying + console.log("Session may be temporarily unavailable, retrying..."); + window.statusCheckTimer = setTimeout(function () { + checkGenerationStatus(); + }, 2000); + return null; // Don't continue processing current response + } + throw new Error(`Status error: ${response.status}`); + } + return response.json(); + }) + .then(data => { + if (!data) return; // If null (due to session issue), return immediately + + console.log("Generation status:", data); + + if (data.status === 'error') { + // If session invalid error, try to restart generation process + if (data.error_message === 'Invalid session') { + console.log("Session expired, refreshing page..."); + alert("Your session has expired. The page will refresh."); + window.location.reload(); + return; + } + + alert('Error: ' + data.error_message); + resetUI(); + return; // Immediately stop polling + } + + if (data.status === 'completed') { + // Confirm again that there is a file before displaying completion message + if (data.file) { + // Save current file name for future check to avoid duplicates + const currentFile = data.file; + + // Ensure file contains current session ID to prevent downloading incorrect file + if (!currentFile.includes(sessionId)) { + console.error("File does not match current session:", currentFile, sessionId); + alert('Error: Generated file does not match current session. Please try again.'); + resetUI(); + return; + } + + // Use timestamp to ensure file name is unique, avoid browser cache + const timestamp = Date.now(); + const downloadUrl = `${getApiUrl(`download/${currentFile}`)}?t=${timestamp}`; + console.log(`Preparing to download from: ${downloadUrl}`); + + // Clear previous polling timer + if (window.statusCheckTimer) { + clearTimeout(window.statusCheckTimer); + window.statusCheckTimer = null; + } + + // Create download link element + const link = document.createElement('a'); + hideGenerationStatus(); // Hide status text + + // Use timestamp URL to avoid browser cache + link.href = downloadUrl; + link.download = currentFile; + document.body.appendChild(link); // Add to DOM + + // Show success message based on whether it's partial or complete + if (data.partial) { + alert('Generation stopped. Partial data saved and will be downloaded.'); + } else { + alert('Stimulus generation complete!'); + } + + // Clear waiting flag + window.waitingForStopData = false; + + // Download file + setTimeout(() => { + link.click(); + // Remove link from DOM after completion + setTimeout(() => { + document.body.removeChild(link); + }, 100); + }, 500); + + // Reset UI state + resetUI(); + + // Reset polling counter + window.retryCount = 0; + } else { + // If there is no file but status is completed, this is an error + console.error("Status reported as completed but no file was returned"); + alert('Generation completed but no file was produced. Please try again.'); + resetUI(); + } + } else if (data.status === 'stopped') { + // Generation stopped with no partial data available + console.log("Generation has been stopped by user with no data to save"); + window.waitingForStopData = false; + alert('Generation stopped. No data was generated.'); + resetUI(); + } else if (data.status === 'running') { + updateProgress(data.progress); + + // Check if we're in stopping state (waiting for partial data to be saved) + if (data.stopping) { + console.log("Stopping... waiting for partial data to be saved"); + // Show stopping status + const statusTextElement = document.getElementById('generation-status-text'); + if (statusTextElement) { + statusTextElement.textContent = 'Stopping... Saving partial data...'; + statusTextElement.style.display = 'block'; + } + // Poll more frequently while stopping + window.statusCheckTimer = setTimeout(function () { + checkGenerationStatus(); + }, 500); + return; + } + + // Add extra check - if progress is 100 but status is still running + if (data.progress >= 100) { + console.log("Progress is 100% but status is still running, waiting for file..."); + // Set to "Checking status & Creating file..." + showCheckingStatus(); + // Give server more time to complete file generation + window.statusCheckTimer = setTimeout(function () { + checkGenerationStatus(); + }, 2000); // Extend to 2 seconds to wait for file generation + } else { + // Ensure "Generating..." text is displayed + if (data.progress > 0 && data.progress < 100) { + showGeneratingStatus(); + } + + // Adjust polling frequency based on progress, the higher the progress, the more frequent the check + let pollInterval = 1000; // Default 1 second + if (data.progress > 0) { + // When progress is greater than 0, check more frequently + pollInterval = 500; // Change to 0.5 seconds + } + window.statusCheckTimer = setTimeout(function () { + checkGenerationStatus(); + }, pollInterval); + } + } else { + // Handle unknown status + console.error("Unknown status received:", data.status); + alert("Received unexpected status from server. Generation may have failed. Please try again later."); + resetUI(); + } + }) + .catch(error => { + console.error('Error checking generation status:', error); + // If there is an error, wait 3 seconds before retrying, but only up to 3 times + if (!window.retryCount) window.retryCount = 0; + window.retryCount++; + + if (window.retryCount <= 3) { + console.log(`Retrying status check (${window.retryCount}/3)...`); + window.statusCheckTimer = setTimeout(function () { + checkGenerationStatus(); + }, 3000); + } else { + window.retryCount = 0; // Reset counter + alert('Error checking generation status. Please try again.'); + resetUI(); + } + }); +} + +stopButton.addEventListener('click', () => { + // Add confirmation dialogue box + const confirmStop = confirm('Are you sure you want to stop? Partial data will be saved if available.'); + + // Only execute stop operation when user clicks "Yes" + if (confirmStop) { + fetch(getApiUrl('stop_generation'), { method: 'POST' }) + .then(response => response.json()) + .then(data => { + console.log('Stop response:', data.message); + // Don't immediately stop polling - continue to wait for partial data + // The status check will handle the download when partial data is ready + // Set a flag to indicate we're waiting for stop to complete + window.waitingForStopData = true; + // Continue polling for a bit to get partial data + if (!window.statusCheckTimer) { + checkGenerationStatus(); + } + }) + .catch(error => { + console.error('Error stopping generation:', error); + alert('Failed to stop generation. Please try again in a few seconds.'); + }); + } + // If user clicks "No", do nothing +}); + +clearButton.addEventListener('click', () => { + // Reset model selection + document.getElementById('model_choice').value = ''; + // Disable API key input box + document.getElementById('api_key').disabled = true; + + const textAreas = [ + 'agent1_properties', + 'agent2_properties', + 'agent3_properties', + 'api_key', + 'iteration', + 'experiment_design' + ]; + textAreas.forEach(id => { + const element = document.getElementById(id); + if (element) { + element.value = ''; // Clear text box and text area values + } + }); + + // Clear custom model configuration fields + const customModelFields = [ + 'custom_model_name', + 'custom_api_url', + 'custom_params' + ]; + customModelFields.forEach(id => { + const element = document.getElementById(id); + if (element) { + element.value = ''; + } + }); + + // Reset checkboxes to default state + const agent2IndividualValidation = document.getElementById('agent2_individual_validation'); + if (agent2IndividualValidation) { + agent2IndividualValidation.checked = false; // Set to false as per requirement #4 + } + + const agent3IndividualScoring = document.getElementById('agent3_individual_scoring'); + if (agent3IndividualScoring) { + agent3IndividualScoring.checked = false; + } + + // Hide custom model configuration section + const customModelConfig = document.getElementById('custom_model_config'); + if (customModelConfig) { + customModelConfig.style.display = 'none'; + } + + // Clear all Items, only keep one initial Item + while (itemsContainer.firstChild) { + itemsContainer.removeChild(itemsContainer.firstChild); + } + + // Create new Item 1 + const newItem = document.createElement('div'); + newItem.className = 'item-container'; + newItem.id = 'item-1'; + newItem.innerHTML = ` +
Item 1
+ + + + + + + + + + + + + +
ComponentsContent
+
+
+ + +
+
+ +
+
+ `; + itemsContainer.appendChild(newItem); + + // Add event listener to new buttons + newItem.querySelector('.add-component-btn').addEventListener('click', addComponentToAllItems); + newItem.querySelector('.delete-component-btn').addEventListener('click', deleteComponentFromAllItems); + newItem.querySelector('.add-item-btn').addEventListener('click', addNewItem); + + // Add Agent 2 table clear functionality + const agent2Rows = Array.from(agent2Table.rows).slice(1); // Skip header + // First delete extra rows + while (agent2Table.rows.length > 2) { + agent2Table.deleteRow(2); // Always delete second row and all following rows + } + // Clear input in first row of two columns + agent2Table.rows[1].cells[0].querySelector('input').value = ''; + agent2Table.rows[1].cells[1].querySelector('input').value = ''; + + // Add Agent 3 table clear functionality + const agent3Rows = Array.from(agent3PropertiesTable.rows).slice(1); // Skip header + // First delete extra rows + while (agent3PropertiesTable.rows.length > 2) { + agent3PropertiesTable.deleteRow(2); // Always delete second row and all following rows + } + // Clear input in first row of four columns + agent3PropertiesTable.rows[1].cells[0].querySelector('input').value = ''; + agent3PropertiesTable.rows[1].cells[1].querySelector('input').value = ''; + agent3PropertiesTable.rows[1].cells[2].querySelector('input').value = ''; + agent3PropertiesTable.rows[1].cells[3].querySelector('input').value = ''; + + // Reset progress bar + updateProgress(0); + + // Clear log area + clearLogs(); +}); + +// Function to periodically check WebSocket status +function periodicSocketCheck() { + if (!socket || !socket.connected) { + if (socketInitialized) { + console.log("Detected WebSocket connection disconnected, resetting connection flag"); + socketInitialized = false; + } + + // Only try to reconnect if there is no connection attempt in progress, and the time since last attempt is reasonable + if (!socketConnectionInProgress && (!socketReconnectTimer || socketReconnectAttempts > 10)) { + console.log("Trying to reconnect WebSocket"); + socketReconnectAttempts = 0; // Reset attempt counter + socketBackoffDelay = 1000; // Reset backoff delay + initializeSocket(); + } + } + + // Check connection status every 30 seconds + setTimeout(periodicSocketCheck, 30000); +} + +// Disable all interactive elements on the page +function disableAllElements() { + // Start safety timeout + startSafetyTimeout(); + + // Disable all dropdowns, text boxes, and text areas + document.querySelectorAll('input, textarea, select').forEach(element => { + element.disabled = true; + }); + + // Disable all buttons (except the AutoGenerate button) + document.querySelectorAll('button').forEach(button => { + if (button.id !== 'auto_generate_button') { + button.disabled = true; + } + }); + + // Add disabled style to tables + document.querySelectorAll('table').forEach(table => { + table.classList.add('disabled-table'); + }); + + // Add semi-transparent overlay to prevent user interaction + const overlay = document.createElement('div'); + overlay.id = 'page-overlay'; + overlay.className = 'page-overlay'; + + // Add loading animation + const spinner = document.createElement('div'); + spinner.className = 'loading-spinner'; + + // Add processing text + const loadingText = document.createElement('div'); + loadingText.className = 'loading-text'; + loadingText.textContent = 'Generating properties...'; + + // Add animation and text to overlay + overlay.appendChild(spinner); + overlay.appendChild(loadingText); + + document.body.appendChild(overlay); +} + +// Enable all interactive elements on the page +function enableAllElements() { + // Clear safety timeout + clearSafetyTimeout(); + + // Enable all dropdowns, text boxes, and text areas + document.querySelectorAll('input, textarea, select').forEach(element => { + // Check if element is API key input box, and model selection requires API key + if (element.id === 'api_key' && modelChoice.value !== 'GPT-4o' && modelChoice.value !== 'custom') { + element.disabled = true; // Keep API key input box disabled + } else { + element.disabled = false; + } + }); + + // Enable all buttons + document.querySelectorAll('button').forEach(button => { + button.disabled = false; + }); + + // Restore Stop button state (should be disabled by default) + if (stopButton) { + stopButton.disabled = true; + } + + // Remove table disabled style + document.querySelectorAll('table').forEach(table => { + table.classList.remove('disabled-table'); + }); + + // Remove page overlay + const overlay = document.getElementById('page-overlay'); + if (overlay) { + document.body.removeChild(overlay); + } +} + +// Modify AutoGenerate button click event +autoGenerateButton.addEventListener('click', function () { + // First validate inputs + if (!validateAutoGenerateInputs()) { + return; + } + + // Show loading status + autoGenerateButton.disabled = true; + autoGenerateButton.innerText = "Generating..."; + + // Disable all other elements on the page + disableAllElements(); + + // Collect example stimuli from tables + const exampleStimuli = collectExampleStimuli(); + // Get experimental design + const experimentDesign = document.getElementById('experiment_design').value.trim(); + + // Build prompt, replace placeholders + let prompt = autoGeneratePromptTemplate + .replace('{Experimental design}', experimentDesign) + .replace('{Example stimuli}', JSON.stringify(exampleStimuli, null, 2)); + + // Record used model and built prompt + const selectedModel = modelChoice.value; + console.log("Model used:", selectedModel); + console.log("Experimental Design:", experimentDesign); + console.log("Example Stimuli:", exampleStimuli); + console.log("Complete Prompt:", prompt); + + if (selectedModel === 'GPT-4o') { + // Use OpenAI API + callOpenAIAPI(prompt); + } else if (selectedModel === 'custom') { + // Use custom model API + callcustomAPI(prompt); + } +}); + +// Modify OpenAI API call function, enable page elements when successful or failed +function callOpenAIAPI(prompt) { + try { + const apiKey = document.getElementById('api_key').value.trim(); + + + // Prepare request body + const requestBody = { + model: "gpt-4o", + messages: [ + { + role: "user", + content: prompt + } + ], + + max_tokens: 2000 + }; + + // Send API request + fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify(requestBody) + }) + .then(response => { + // Check response status + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText.trim()}`); + } + return response.json(); + }) + .then(data => { + // Clear safety timeout when API call succeeds + clearSafetyTimeout(); + + // Process API response + const content = data.choices[0].message.content; + // Wrap processing in try-catch + try { + processAPIResponse(content); + } catch (processingError) { + console.error('Error processing response:', processingError); + // Ensure page is not locked + enableAllElements(); + alert(`Error processing response: ${processingError.message}`); + } + }) + .catch(error => { + console.error('OpenAI API call error:', error); + // Clear safety timeout when API call fails + clearSafetyTimeout(); + // Ensure page is not locked + enableAllElements(); + alert(`OpenAI API call failed: ${error.message}. Please check your input is correct and try again later.`); + }) + .finally(() => { + // Restore button state + autoGenerateButton.disabled = false; + autoGenerateButton.innerText = "AutoGenerate properties"; + }); + } catch (error) { + // Catch any errors that occur before setting up or executing API call + console.error('Error setting up API call:', error); + clearSafetyTimeout(); + enableAllElements(); + autoGenerateButton.disabled = false; + autoGenerateButton.innerText = "AutoGenerate properties"; + alert(`Error setting up API call: ${error.message}`); + } +} + +// Add custom model API call function (routed through backend to avoid CORS) +function callcustomAPI(prompt) { + const apiUrl = document.getElementById('custom_api_url').value.trim(); + const apiKey = document.getElementById('api_key').value.trim(); + const modelName = document.getElementById('custom_model_name').value.trim(); + + // Route through backend proxy to avoid CORS issues + const requestBody = { + session_id: sessionId, + prompt: prompt, + model: modelName, + api_url: apiUrl, + api_key: apiKey + }; + + fetch('/api/custom_model_inference', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }) + .then(response => { + if (!response.ok) { + return response.json().then(err => { + throw new Error(err.error || `API request failed: ${response.status}`); + }); + } + return response.json(); + }) + .then(data => { + // Clear safety timeout when API call succeeds + clearSafetyTimeout(); + + if (data.error) { + throw new Error(data.error); + } + + const content = data.response; + processAPIResponse(content); + }) + .catch(error => { + console.error('Custom model API call error:', error); + clearSafetyTimeout(); + enableAllElements(); + alert(`Custom model API call failed: ${error.message}. Please check your input is correct and try again later.`); + }) + .finally(() => { + autoGenerateButton.disabled = false; + autoGenerateButton.innerText = "AutoGenerate properties"; + }); +} + +// Process API response +function processAPIResponse(response) { + try { + console.log("API response:", response); + + // Clear safety timeout when starting to process response + clearSafetyTimeout(); + + // Extract Requirements dictionary + const requirementsMatch = response.match(/Requirements:\s*\{([^}]+)\}/s); + let requirements = {}; + + if (requirementsMatch && requirementsMatch[1]) { + try { + // Try to parse complete JSON + requirements = JSON.parse(`{${requirementsMatch[1]}}`); + } catch (e) { + console.error("Failed to parse Requirements JSON:", e); + + // Use regular expression to parse key-value pairs + const keyValuePairs = requirementsMatch[1].match(/"([^"]+)":\s*"([^"]+)"/g); + if (keyValuePairs) { + keyValuePairs.forEach(pair => { + const matches = pair.match(/"([^"]+)":\s*"([^"]+)"/); + if (matches && matches.length === 3) { + requirements[matches[1]] = matches[2]; + } + }); + } + } + } + + // Extract Scoring Dimensions dictionary + const scoringMatch = response.match(/Scoring Dimensions:\s*\{([^}]+)\}/s); + let scoringDimensions = {}; + + if (scoringMatch && scoringMatch[1]) { + try { + // Try to parse complete JSON + scoringDimensions = JSON.parse(`{${scoringMatch[1]}}`); + } catch (e) { + console.error("Failed to parse Scoring Dimensions JSON:", e); + + // Use regular expression to parse key-value pairs + const keyValuePairs = scoringMatch[1].match(/"([^"]+)":\s*"([^"]+)"/g); + if (keyValuePairs) { + keyValuePairs.forEach(pair => { + const matches = pair.match(/"([^"]+)":\s*"([^"]+)"/); + if (matches && matches.length === 3) { + scoringDimensions[matches[1]] = matches[2]; + } + }); + } + } + } + + // If no content is found above, try to find curly braces + if (Object.keys(requirements).length === 0 && Object.keys(scoringDimensions).length === 0) { + const jsonMatches = response.match(/\{([^{}]*)\}/g); + if (jsonMatches && jsonMatches.length >= 2) { + try { + requirements = JSON.parse(jsonMatches[0]); + scoringDimensions = JSON.parse(jsonMatches[1]); + } catch (e) { + console.error("Failed to parse JSON objects:", e); + } + } + } + + // Confirm content has been extracted + if (Object.keys(requirements).length === 0 || Object.keys(scoringDimensions).length === 0) { + // First enable all elements, then display error message + enableAllElements(); + throw new Error("Could not extract valid Requirements or Scoring Dimensions from API response."); + } + + // Fill validator table + fillValidatorTable(requirements); + + // Fill scorer table + fillScorerTable(scoringDimensions); + + // Ensure all elements are enabled before displaying completion message + enableAllElements(); + + alert("Auto-generation complete! Please carefully review the content in the Validator and Scorer tables to ensure they meet your experimental design requirements."); + } catch (error) { + console.error("Error processing API response:", error); + // Clear safety timeout even on error + clearSafetyTimeout(); + // Ensure all elements are enabled regardless + enableAllElements(); + alert(`Failed to process API response: ${error.message}. Please try again later.`); + } +} + +// Fill validator table +function fillValidatorTable(requirements) { + // Clear current table content + const tbody = agent2Table.querySelector('tbody'); + const currentRowCount = tbody.querySelectorAll('tr').length; + const requirementsCount = Object.keys(requirements).length; + + console.log(`Validator table: current rows = ${currentRowCount}, required rows = ${requirementsCount}`); + + // Calculate how many rows need to be added or deleted + if (requirementsCount > currentRowCount) { + // Need to add rows + const rowsToAdd = requirementsCount - currentRowCount; + console.log(`Adding ${rowsToAdd} rows to Validator table`); + + // Add rows directly using DOM operations, not through clicking buttons + for (let i = 0; i < rowsToAdd; i++) { + // Create new row + const newRow = document.createElement('tr'); + + // Create and add first cell (property name) + const propertyCell = document.createElement('td'); + propertyCell.className = "agent_2_properties-column"; + const propertyInput = document.createElement('input'); + propertyInput.type = "text"; + propertyInput.placeholder = "Enter new property"; + propertyCell.appendChild(propertyInput); + + // Create and add second cell (description) + const descriptionCell = document.createElement('td'); + descriptionCell.className = "agent_2_description-column"; + const descriptionInput = document.createElement('input'); + descriptionInput.type = "text"; + descriptionInput.placeholder = "Enter property's description"; + descriptionCell.appendChild(descriptionInput); + + // New: Add Delete button + const actionCell = document.createElement('td'); + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'delete-row-btn delete-btn'; + deleteBtn.textContent = 'Delete'; + actionCell.appendChild(deleteBtn); + newRow.appendChild(propertyCell); + newRow.appendChild(descriptionCell); + newRow.appendChild(actionCell); + + // Add row to table + tbody.appendChild(newRow); + } + } else if (requirementsCount < currentRowCount) { + // Need to delete rows + const rowsToDelete = currentRowCount - requirementsCount; + console.log(`Deleting ${rowsToDelete} rows from Validator table`); + + // Delete extra rows, starting from the last row + for (let i = 0; i < rowsToDelete; i++) { + tbody.removeChild(tbody.lastElementChild); + } + } + + // Get updated rows and fill in content + const updatedRows = agent2Table.querySelectorAll('tbody tr'); + let index = 0; + + for (const [key, value] of Object.entries(requirements)) { + if (index < updatedRows.length) { + const cells = updatedRows[index].querySelectorAll('input'); + if (cells.length >= 2) { + cells[0].value = key; + cells[1].value = value; + } + index++; + } + } +} + +// Fill scorer table +function fillScorerTable(scoringDimensions) { + // Clear current table content + const tbody = agent3PropertiesTable.querySelector('tbody'); + const currentRowCount = tbody.querySelectorAll('tr').length; + const dimensionsCount = Object.keys(scoringDimensions).length; + + console.log(`Scorer table: current rows = ${currentRowCount}, required rows = ${dimensionsCount}`); + + // Calculate how many rows need to be added or deleted + if (dimensionsCount > currentRowCount) { + // Need to add rows + const rowsToAdd = dimensionsCount - currentRowCount; + console.log(`Adding ${rowsToAdd} rows to Scorer table`); + + // Add rows directly using DOM operations, not through clicking buttons + for (let i = 0; i < rowsToAdd; i++) { + // Create new row + const newRow = document.createElement('tr'); + + // Create and add first cell (aspect name) + const aspectCell = document.createElement('td'); + aspectCell.className = "agent_3_properties-column"; + const aspectInput = document.createElement('input'); + aspectInput.type = "text"; + aspectInput.placeholder = "Enter new aspect"; + aspectCell.appendChild(aspectInput); + + // Create and add second cell (description) + const descriptionCell = document.createElement('td'); + descriptionCell.className = "agent_3_description-column"; + const descriptionInput = document.createElement('input'); + descriptionInput.type = "text"; + descriptionInput.placeholder = "Enter aspect's description"; + descriptionCell.appendChild(descriptionInput); + + // Create and add third cell (minimum value) + const minCell = document.createElement('td'); + minCell.className = "agent_3_minimum-column"; + const minInput = document.createElement('input'); + minInput.type = "number"; + minInput.min = "0"; + minInput.placeholder = "e.g. 0"; + minCell.appendChild(minInput); + + // Create and add fourth cell (maximum value) + const maxCell = document.createElement('td'); + maxCell.className = "agent_3_maximum-column"; + const maxInput = document.createElement('input'); + maxInput.type = "number"; + maxInput.min = "0"; + maxInput.placeholder = "e.g. 10"; + maxCell.appendChild(maxInput); + + // New: Add Delete button + const actionCell = document.createElement('td'); + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'delete-row-btn delete-btn'; + deleteBtn.textContent = 'Delete'; + actionCell.appendChild(deleteBtn); + newRow.appendChild(aspectCell); + newRow.appendChild(descriptionCell); + newRow.appendChild(minCell); + newRow.appendChild(maxCell); + newRow.appendChild(actionCell); + + // Add row to table + tbody.appendChild(newRow); + } + } else if (dimensionsCount < currentRowCount) { + // Need to delete rows + const rowsToDelete = currentRowCount - dimensionsCount; + console.log(`Deleting ${rowsToDelete} rows from Scorer table`); + + // Delete extra rows, starting from the last row + for (let i = 0; i < rowsToDelete; i++) { + tbody.removeChild(tbody.lastElementChild); + } + } + + // Get updated rows and fill in content + const updatedRows = agent3PropertiesTable.querySelectorAll('tbody tr'); + let index = 0; + + for (const [key, value] of Object.entries(scoringDimensions)) { + if (index < updatedRows.length) { + const cells = updatedRows[index].querySelectorAll('input'); + if (cells.length >= 4) { + cells[0].value = key; + cells[1].value = value; + cells[2].value = '0'; // Default minimum value + cells[3].value = '10'; // Default maximum value + } + index++; + } + } +} + +// Add global error handler, ensure page is not locked +window.addEventListener('error', function (event) { + console.error('Global error:', event.error || event.message); + + // If page overlay exists and has been active for more than 30 seconds, remove it automatically + const overlay = document.getElementById('page-overlay'); + if (overlay && document.body.contains(overlay)) { + console.warn('Detected global error, removing possibly frozen page overlay'); + clearSafetyTimeout(); + document.body.removeChild(overlay); + + // Restore normal page interaction + enableUI(); + } +}); + +// Add safety timeout, ensure page is unlocked after 90 seconds +function startSafetyTimeout() { + console.log('Starting safety timeout'); + window.safetyTimeoutId = setTimeout(function () { + console.log('Safety timeout triggered'); + if (document.getElementById('page-overlay')) { + console.log('Removing overlay due to safety timeout'); + enableAllElements(); + alert("Operation timed out. The page has been unlocked. Please try again."); + autoGenerateButton.disabled = false; + autoGenerateButton.innerText = "AutoGenerate properties"; + } + }, 90000); // 90 second timeout +} + +function clearSafetyTimeout() { + if (window.safetyTimeoutId) { + console.log('Clearing safety timeout'); + clearTimeout(window.safetyTimeoutId); + window.safetyTimeoutId = null; + } +} + +// Add UI safety timeout protection, ensure interface is not locked for more than 30 seconds +function setupUIProtection() { + // Global timeout protection: ensure page is not locked for more than 30 seconds + window.uiProtectionTimeout = null; +} + +// Modify disableUI function to add timeout protection +function disableUI() { + // Disable main buttons and input elements + generateButton.disabled = true; + clearButton.disabled = true; + addAgent2Button.disabled = true; + addAgent3Button.disabled = true; + modelSelect.disabled = true; + apiKeyInput.disabled = true; + + // Enable Stop button + stopButton.disabled = false; + + // Add element to display "Generating..." status + updateGenerationStatus('generating'); + + // Create and display page overlay + createPageOverlay("Generating stimuli, please wait..."); + + // Set UI protection timeout, ensure interface is not locked indefinitely + if (window.uiProtectionTimeout) { + clearTimeout(window.uiProtectionTimeout); + } + + window.uiProtectionTimeout = setTimeout(() => { + console.warn("UI protection timeout triggered - interface will automatically restore after 90 seconds"); + enableUI(); + + // Remove overlay (if it exists) + const overlay = document.getElementById('page-overlay'); + if (overlay && document.body.contains(overlay)) { + document.body.removeChild(overlay); + } + + // Display warning message + alert("Operation timed out. If you are waiting for the generation result, please try again later."); + }, 90000); // 90 second timeout +} + +// Modify enableUI function to clear timeout protection +function enableUI() { + // Enable main buttons and input elements + generateButton.disabled = false; + clearButton.disabled = false; + addAgent2Button.disabled = false; + addAgent3Button.disabled = false; + modelSelect.disabled = false; + + // Determine if API key input box is available based on current model selection + handleModelChange(); + + // Disable Stop button + stopButton.disabled = true; + + // Clear "Generating..." status display + updateGenerationStatus('idle'); + + // Remove overlay (if it exists) + const overlay = document.getElementById('page-overlay'); + if (overlay && document.body.contains(overlay)) { + document.body.removeChild(overlay); + } + + // Clear UI protection timeout + if (window.uiProtectionTimeout) { + clearTimeout(window.uiProtectionTimeout); + window.uiProtectionTimeout = null; + } +} + +// Modify createPageOverlay function to add click event handling +function createPageOverlay(message) { + // If overlay already exists, remove it first + const existingOverlay = document.getElementById('page-overlay'); + if (existingOverlay) { + document.body.removeChild(existingOverlay); + } + + // Create overlay element + const overlay = document.createElement('div'); + overlay.id = 'page-overlay'; + overlay.className = 'page-overlay'; + + // Create loading animation + const spinner = document.createElement('div'); + spinner.className = 'loading-spinner'; + overlay.appendChild(spinner); + + // Create loading text + const loadingText = document.createElement('div'); + loadingText.className = 'loading-text'; + loadingText.textContent = message || 'Loading...'; + overlay.appendChild(loadingText); + + // Add to page + document.body.appendChild(overlay); + return overlay; +} + +// Add event delegation for row deletion in Validator table +agent2Table.addEventListener('click', function (e) { + if (e.target && e.target.classList.contains('delete-row-btn')) { + const row = e.target.closest('tr'); + if (row.parentNode.rows.length > 1) { + row.remove(); + } + } +}); + +// Add event delegation for row deletion in Scorer table +agent3PropertiesTable.addEventListener('click', function (e) { + if (e.target && e.target.classList.contains('delete-row-btn')) { + const row = e.target.closest('tr'); + if (row.parentNode.rows.length > 1) { + row.remove(); + } + } +}); + +// ===== RESTART COUNTDOWN FUNCTIONALITY ===== + +/** + * Initialize the restart countdown system + * This calculates when to show the countdown (20 minutes before restart) + * and sets up the timer to start the countdown at the right time + */ +function initializeRestartCountdown() { + try { + // Calculate when the app will restart (RESTART_INTERVAL seconds from now) + const appStartTime = Date.now(); + const restartTime = appStartTime + (RESTART_INTERVAL * 1000); + + // Calculate when to start the countdown (20 minutes before restart) + const countdownStartTime = restartTime - (COUNTDOWN_DURATION * 1000); + const timeUntilCountdownStart = countdownStartTime - Date.now(); + + console.log(`App started at: ${new Date(appStartTime).toLocaleString()}`); + console.log(`Restart scheduled for: ${new Date(restartTime).toLocaleString()}`); + console.log(`Countdown will start at: ${new Date(countdownStartTime).toLocaleString()}`); + console.log(`Time until countdown starts: ${Math.round(timeUntilCountdownStart / 1000)} seconds`); + + // If countdown should start immediately (for testing or if app was restarted recently) + if (timeUntilCountdownStart <= 0) { + const remainingTime = Math.max(0, Math.round((restartTime - Date.now()) / 1000)); + if (remainingTime > 0) { + console.log(`Starting countdown immediately with ${remainingTime} seconds remaining`); + startRestartCountdown(remainingTime); + } + } else { + // Set timer to start countdown at the right time + countdownStartTimer = setTimeout(() => { + startRestartCountdown(COUNTDOWN_DURATION); + }, timeUntilCountdownStart); + + console.log(`Countdown timer set to start in ${Math.round(timeUntilCountdownStart / 1000)} seconds`); + } + } catch (error) { + console.error('Error initializing restart countdown:', error); + } +} + +/** + * Start the visual countdown timer + * @param {number} initialSeconds - Number of seconds to count down from + */ +function startRestartCountdown(initialSeconds) { + try { + let remainingSeconds = initialSeconds; + + // Show the countdown element + const countdownElement = document.getElementById('restart-countdown'); + const countdownTimeElement = document.getElementById('countdown-time'); + + if (!countdownElement || !countdownTimeElement) { + console.error('Countdown elements not found in DOM'); + return; + } + + countdownElement.style.display = 'block'; + + // Update countdown display immediately + updateCountdownDisplay(remainingSeconds); + + console.log(`Starting restart countdown: ${remainingSeconds} seconds`); + + // Start the countdown timer (update every second) + restartCountdownTimer = setInterval(() => { + remainingSeconds--; + + if (remainingSeconds <= 0) { + // Countdown finished + stopRestartCountdown(); + console.log('Restart countdown completed - app should restart now'); + } else { + updateCountdownDisplay(remainingSeconds); + } + }, 1000); + + } catch (error) { + console.error('Error starting restart countdown:', error); + } +} + +/** + * Update the countdown display with current time + * @param {number} seconds - Remaining seconds + */ +function updateCountdownDisplay(seconds) { + try { + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + const timeString = `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + + const countdownTimeElement = document.getElementById('countdown-time'); + if (countdownTimeElement) { + countdownTimeElement.textContent = timeString; + } + + // Add visual urgency as time runs out + const countdownElement = document.getElementById('restart-countdown'); + if (countdownElement) { + if (seconds <= 60) { + // Last minute - add urgent animation + countdownElement.style.animationDuration = '0.5s'; + } else if (seconds <= 300) { + // Last 5 minutes - speed up animation + countdownElement.style.animationDuration = '1s'; + } + } + + } catch (error) { + console.error('Error updating countdown display:', error); + } +} + +/** + * Stop and hide the countdown timer + */ +function stopRestartCountdown() { + try { + // Clear the countdown timer + if (restartCountdownTimer) { + clearInterval(restartCountdownTimer); + restartCountdownTimer = null; + } + + // Hide the countdown element + const countdownElement = document.getElementById('restart-countdown'); + if (countdownElement) { + countdownElement.style.display = 'none'; + } + + console.log('Restart countdown stopped and hidden'); + + } catch (error) { + console.error('Error stopping restart countdown:', error); + } +} + +/** + * Cleanup countdown timers (called when page unloads or app shuts down) + */ +function cleanupCountdownTimers() { + try { + if (countdownStartTimer) { + clearTimeout(countdownStartTimer); + countdownStartTimer = null; + } + + if (restartCountdownTimer) { + clearInterval(restartCountdownTimer); + restartCountdownTimer = null; + } + + console.log('Countdown timers cleaned up'); + + } catch (error) { + console.error('Error cleaning up countdown timers:', error); + } +} + +// Modify addAgent2Button click handler to add Delete button in new row +addAgent2Button.addEventListener('click', function () { + const tbody = agent2Table.querySelector('tbody'); + const newRow = document.createElement('tr'); + newRow.innerHTML = ` + + + + `; + tbody.appendChild(newRow); +}); + +// Modify addAgent3Button click handler to add Delete button in new row +addAgent3Button.addEventListener('click', function () { + const tbody = agent3PropertiesTable.querySelector('tbody'); + const newRow = document.createElement('tr'); + newRow.innerHTML = ` + + + + + + `; + tbody.appendChild(newRow); +});