UNPKG

navflow-browser-server

Version:

Standalone Playwright browser server for NavFlow - enables browser automation with API key authentication, workspace device management, session sync, and requires Node.js v22+

831 lines (766 loc) β€’ 33.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.HumanLoopBannerService = void 0; const events_1 = require("events"); class HumanLoopBannerService extends events_1.EventEmitter { constructor() { super(...arguments); this.activeSessions = new Map(); this.timeoutHandlers = new Map(); } /** * Show the human-in-the-loop banner on a page */ async showBanner(sessionId, nodeId, page, prompt, timeout = 300, deviceApiKey, proxyServerUrl) { const sessionKey = `${sessionId}_${nodeId}`; // Clean up any existing session await this.hideBanner(sessionKey); const session = { nodeId, sessionId, prompt, timeout, startTime: new Date(), isActive: true, page }; this.activeSessions.set(sessionKey, session); // Wait for page to stabilize before injecting banner await page.waitForTimeout(2000); // Inject the banner into the page await this.injectBanner(page, sessionKey, prompt, timeout, deviceApiKey, proxyServerUrl); // Monitor and re-inject banner if it gets removed await this.startBannerMonitoring(page, sessionKey, prompt, timeout, deviceApiKey, proxyServerUrl); // Set up page event listener for banner actions const handlePageEvent = async () => { try { const action = await page.evaluate('window.navflowBannerAction'); if (action && action.sessionKey === sessionKey) { console.log('🎯 Detected banner action:', action); // Clear the action to prevent duplicate processing await page.evaluate('window.navflowBannerAction = null'); // Handle the action if (action.type === 'extend') { await this.handleUserResponse(sessionKey, 'extend', undefined, action.extensionTime); } else if (action.type === 'complete') { await this.handleUserResponse(sessionKey, 'complete', action.userResponse); } else if (action.type === 'reset') { await this.handleUserResponse(sessionKey, 'reset', undefined, undefined, action.resetTimeout); } } } catch (error) { console.error('Error handling page event:', error); } }; // Poll for banner actions every 100ms const pollInterval = setInterval(handlePageEvent, 100); session.pollInterval = pollInterval; // Set up the timeout handler const timeoutHandler = setTimeout(() => { clearInterval(pollInterval); this.handleTimeout(sessionKey); }, timeout * 1000); this.timeoutHandlers.set(sessionKey, timeoutHandler); // Return a promise that resolves when user interacts return new Promise((resolve) => { this.once(`response_${sessionKey}`, (response) => { clearInterval(pollInterval); resolve(response); }); }); } /** * Hide the banner from a page */ async hideBanner(sessionKey) { const session = this.activeSessions.get(sessionKey); if (!session) return; try { // Remove banner and clean up monitoring await session.page.evaluate(` (() => { const banner = document.getElementById('navflow-human-loop-banner'); if (banner) { banner.remove(); } // Clean up monitoring if (window.navflowMonitorInterval) { clearInterval(window.navflowMonitorInterval); window.navflowMonitorInterval = null; } if (window.navflowBannerObserver) { window.navflowBannerObserver.disconnect(); window.navflowBannerObserver = null; } if (window.navflowTimerInterval) { clearInterval(window.navflowTimerInterval); window.navflowTimerInterval = null; } // Restore original history methods if they were overridden if (window.originalPushState) { history.pushState = window.originalPushState; window.originalPushState = null; } if (window.originalReplaceState) { history.replaceState = window.originalReplaceState; window.originalReplaceState = null; } // Clean up session data and functions window.navflowSession = null; window.navflowBannerAction = null; window.navflowInjectBanner = null; window.navflowReinjecting = null; window.navflowInjecting = null; })() `); } catch (error) { console.warn('Failed to remove banner from page:', error); } // Clear timeout const timeoutHandler = this.timeoutHandlers.get(sessionKey); if (timeoutHandler) { clearTimeout(timeoutHandler); this.timeoutHandlers.delete(sessionKey); } // Clear poll interval if (session.pollInterval) { clearInterval(session.pollInterval); } // Clean up session this.activeSessions.delete(sessionKey); } /** * Handle user response from the banner */ async handleUserResponse(sessionKey, action, userResponse, extensionTime, resetTimeout) { const session = this.activeSessions.get(sessionKey); if (!session || !session.isActive) return; session.isActive = false; const response = { action, userResponse, extensionTime, resetTimeout }; if (action === 'extend' && extensionTime) { // Extend the timeout session.timeout += extensionTime; session.isActive = true; // Clear old timeout and set new one const oldHandler = this.timeoutHandlers.get(sessionKey); if (oldHandler) { clearTimeout(oldHandler); } // Clear old poll interval and start new one (if it exists) if (session.pollInterval) { clearInterval(session.pollInterval); } // Restart polling for this session const handlePageEvent = async () => { try { const action = await session.page.evaluate('window.navflowBannerAction'); if (action && action.sessionKey === sessionKey) { console.log('🎯 Detected banner action after extension:', action); // Clear the action to prevent duplicate processing await session.page.evaluate('window.navflowBannerAction = null'); // Handle the action if (action.type === 'extend') { await this.handleUserResponse(sessionKey, 'extend', undefined, action.extensionTime); } else if (action.type === 'complete') { await this.handleUserResponse(sessionKey, 'complete', action.userResponse); } else if (action.type === 'reset') { await this.handleUserResponse(sessionKey, 'reset', undefined, undefined, action.resetTimeout); } } } catch (error) { console.error('Error handling page event after extension:', error); } }; const newPollInterval = setInterval(handlePageEvent, 100); session.pollInterval = newPollInterval; const newHandler = setTimeout(() => { clearInterval(newPollInterval); this.handleTimeout(sessionKey); }, extensionTime * 1000); this.timeoutHandlers.set(sessionKey, newHandler); // Update banner with new time await this.updateBannerTime(session.page, sessionKey, session.timeout); return; } if (action === 'reset' && resetTimeout) { // Reset the timeout to original value session.timeout = resetTimeout; session.startTime = new Date(); session.isActive = true; // Clear old timeout and set new one const oldHandler = this.timeoutHandlers.get(sessionKey); if (oldHandler) { clearTimeout(oldHandler); } // Clear old poll interval and start new one (if it exists) if (session.pollInterval) { clearInterval(session.pollInterval); } // Restart polling for this session const handlePageEvent = async () => { try { const action = await session.page.evaluate('window.navflowBannerAction'); if (action && action.sessionKey === sessionKey) { console.log('🎯 Detected banner action after reset:', action); // Clear the action to prevent duplicate processing await session.page.evaluate('window.navflowBannerAction = null'); // Handle the action if (action.type === 'extend') { await this.handleUserResponse(sessionKey, 'extend', undefined, action.extensionTime); } else if (action.type === 'complete') { await this.handleUserResponse(sessionKey, 'complete', action.userResponse); } else if (action.type === 'reset') { await this.handleUserResponse(sessionKey, 'reset', undefined, undefined, action.resetTimeout); } else if (action.type === 'reset') { await this.handleUserResponse(sessionKey, 'reset', undefined, undefined, action.resetTimeout); } } } catch (error) { console.error('Error handling page event after reset:', error); } }; const newPollInterval = setInterval(handlePageEvent, 100); session.pollInterval = newPollInterval; const newHandler = setTimeout(() => { clearInterval(newPollInterval); this.handleTimeout(sessionKey); }, resetTimeout * 1000); this.timeoutHandlers.set(sessionKey, newHandler); // Update banner with reset time await this.updateBannerTime(session.page, sessionKey, session.timeout, true); return; } // Hide banner and emit response await this.hideBanner(sessionKey); this.emit(`response_${sessionKey}`, response); } /** * Handle timeout */ handleTimeout(sessionKey) { const session = this.activeSessions.get(sessionKey); if (!session) return; session.isActive = false; this.hideBanner(sessionKey); this.emit(`response_${sessionKey}`, { action: 'timeout' }); } /** * Inject the banner HTML/CSS/JS into the page */ async injectBanner(page, sessionKey, prompt, timeout, deviceApiKey, proxyServerUrl) { // Use proxy-server URL if provided, otherwise fallback to browser-server const serverPort = process.env.BROWSER_SERVER_PORT || 3002; const fallbackUrl = `http://localhost:${serverPort}`; const serverUrl = proxyServerUrl || fallbackUrl; // Inject banner using a function string to avoid TypeScript DOM issues const bannerScript = ` (() => { const sessionKey = '${sessionKey}'; const prompt = '${prompt.replace(/'/g, "\\'")}'; const timeout = ${timeout}; const serverUrl = '${serverUrl}'; const deviceApiKey = '${deviceApiKey || ''}'; // Remove existing banner if any const existingBanner = document.getElementById('navflow-human-loop-banner'); if (existingBanner) { existingBanner.remove(); } // Create banner HTML const bannerHTML = \` <div id="navflow-human-loop-banner" style=" position: fixed; top: 0; left: 0; right: 0; z-index: 999999; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 16px 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); border-bottom: 3px solid #4c51bf; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.5; user-select: none; "> <div style="display: flex; align-items: center; justify-content: space-between; max-width: 1200px; margin: 0 auto;"> <div style="display: flex; align-items: center; gap: 12px; flex: 1;"> <div style=" width: 32px; height: 32px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 16px; ">⏸️</div> <div> <div style="font-weight: 600; margin-bottom: 4px;">Automation Paused - Human Input Required</div> <div style="opacity: 0.9; font-size: 13px;">\${prompt}</div> </div> </div> <div style="display: flex; align-items: center; gap: 16px;"> <div style="display: flex; align-items: center; gap: 8px;"> <span style="font-size: 12px; opacity: 0.8;">Time remaining:</span> <span id="navflow-timer" style=" font-weight: 700; font-size: 16px; min-width: 50px; text-align: center; ">\${Math.floor(timeout / 60)}:\${(timeout % 60).toString().padStart(2, '0')}</span> </div> <div style="display: flex; gap: 8px;"> <button id="navflow-reset-btn" style=" background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); color: white; padding: 6px 12px; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.2s; ">Reset Timer</button> <button id="navflow-complete-btn" style=" background: #48bb78; border: 1px solid #38a169; color: white; padding: 6px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.2s; ">Complete</button> </div> </div> </div> </div> \`; // Insert banner at the top of the body document.body.insertAdjacentHTML('afterbegin', bannerHTML); // Wait a moment for DOM to update, then add event listeners setTimeout(() => { console.log('Setting up banner event listeners...'); const resetBtn = document.getElementById('navflow-reset-btn'); const completeBtn = document.getElementById('navflow-complete-btn'); console.log('Found buttons:', { resetBtn: !!resetBtn, completeBtn: !!completeBtn }); if (resetBtn) { // Hover effects resetBtn.addEventListener('mouseenter', () => { resetBtn.style.background = 'rgba(255,255,255,0.3)'; }); resetBtn.addEventListener('mouseleave', () => { resetBtn.style.background = 'rgba(255,255,255,0.2)'; }); // Click handler resetBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); console.log('πŸ”„ Reset timer button clicked!', { sessionKey }); // Store the action for Playwright to detect window.navflowBannerAction = { type: 'reset', sessionKey: sessionKey, resetTimeout: timeout, timestamp: Date.now() }; // Dispatch custom event that Playwright can listen for window.dispatchEvent(new CustomEvent('navflow-banner-action', { detail: window.navflowBannerAction })); }); } if (completeBtn) { // Hover effects completeBtn.addEventListener('mouseenter', () => { completeBtn.style.background = '#38a169'; }); completeBtn.addEventListener('mouseleave', () => { completeBtn.style.background = '#48bb78'; }); // Click handler completeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); console.log('βœ… Complete button clicked!', { sessionKey }); // Store the action for Playwright to detect window.navflowBannerAction = { type: 'complete', sessionKey: sessionKey, userResponse: 'User completed task', timestamp: Date.now() }; // Dispatch custom event that Playwright can listen for window.dispatchEvent(new CustomEvent('navflow-banner-action', { detail: window.navflowBannerAction })); }); } }, 100); // Store session info and start timer window.navflowSession = { sessionKey, timeout, startTime: Date.now() }; // Start countdown timer const timer = document.getElementById('navflow-timer'); const startTime = Date.now(); const updateTimer = () => { const elapsed = Math.floor((Date.now() - startTime) / 1000); const remaining = Math.max(0, timeout - elapsed); if (remaining <= 0) { if (timer) timer.textContent = '0:00'; return; } const minutes = Math.floor(remaining / 60); const seconds = remaining % 60; if (timer) { timer.textContent = \`\${minutes}:\${seconds.toString().padStart(2, '0')}\`; } }; updateTimer(); const timerInterval = setInterval(updateTimer, 1000); window.navflowTimerInterval = timerInterval; })(); `; await page.evaluate(bannerScript); } /** * Update the timer display in the banner */ async updateBannerTime(page, sessionKey, newTimeout, isReset = false) { const updateScript = ` (() => { const newTimeout = ${newTimeout}; // Update session timeout if (window.navflowSession) { window.navflowSession.timeout = newTimeout; window.navflowSession.startTime = Date.now(); } // Show feedback const banner = document.getElementById('navflow-human-loop-banner'); if (banner) { if (${isReset}) { // Flash blue to indicate reset banner.style.background = 'linear-gradient(135deg, #4299e1 0%, #3182ce 100%)'; } else { // Flash green to indicate extension banner.style.background = 'linear-gradient(135deg, #48bb78 0%, #38a169 100%)'; } setTimeout(() => { banner.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; }, 1000); } })(); `; await page.evaluate(updateScript); } /** * Get active sessions */ getActiveSessions() { return Array.from(this.activeSessions.keys()); } /** * Monitor the page and re-inject banner if it gets removed */ async startBannerMonitoring(page, sessionKey, prompt, timeout, deviceApiKey, proxyServerUrl) { const session = this.activeSessions.get(sessionKey); if (!session) return; const monitorScript = ` (() => { const sessionKey = '${sessionKey}'; const prompt = '${prompt.replace(/'/g, "\\'")}'; const timeout = ${timeout}; const serverUrl = '${proxyServerUrl || `http://localhost:${process.env.BROWSER_SERVER_PORT || 3002}`}'; const deviceApiKey = '${deviceApiKey || ''}'; // Store original inject function window.navflowInjectBanner = ${this.getBannerInjectionScript()}; // Enhanced banner monitoring with persistence and duplicate prevention const ensureBannerPresence = () => { const existingBanner = document.getElementById('navflow-human-loop-banner'); const hasActiveSession = window.navflowSession && window.navflowSession.sessionKey === sessionKey; // Only proceed if we have an active session if (!hasActiveSession) { return; } // If banner exists and belongs to current session, do nothing if (existingBanner) { return; } // Prevent multiple simultaneous re-injections if (window.navflowReinjecting) { return; } console.log('⚠️ Banner missing, re-injecting with current state...'); window.navflowReinjecting = true; try { // Calculate remaining time based on original start time const originalTimeout = window.navflowSession.timeout; const startTime = window.navflowSession.startTime; const elapsed = Math.floor((Date.now() - startTime) / 1000); const remainingTimeout = Math.max(0, originalTimeout - elapsed); if (remainingTimeout > 0) { // Double-check no banner was created while we were calculating const doubleCheckBanner = document.getElementById('navflow-human-loop-banner'); if (!doubleCheckBanner) { window.navflowInjectBanner(sessionKey, prompt, remainingTimeout, serverUrl, deviceApiKey); } } } finally { // Clear the re-injection flag after a short delay setTimeout(() => { window.navflowReinjecting = false; }, 1000); } }; // Check every 2 seconds to prevent race conditions const monitorInterval = setInterval(ensureBannerPresence, 2000); window.navflowMonitorInterval = monitorInterval; // Enhanced DOM mutation observer const observer = new MutationObserver((mutations) => { let shouldReinject = false; mutations.forEach((mutation) => { // Check for removed nodes if (mutation.type === 'childList' && mutation.removedNodes.length > 0) { mutation.removedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if the banner itself was removed if (node.id === 'navflow-human-loop-banner') { shouldReinject = true; } // Check if a parent containing the banner was removed if (node.querySelector && node.querySelector('#navflow-human-loop-banner')) { shouldReinject = true; } } }); } // Check for attribute changes that might hide the banner if (mutation.type === 'attributes' && mutation.target && mutation.target.id === 'navflow-human-loop-banner') { const target = mutation.target; const style = window.getComputedStyle(target); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { shouldReinject = true; } } }); if (shouldReinject) { console.log('πŸ” Banner removed/hidden via DOM changes, scheduling re-injection...'); // Longer delay to prevent rapid re-injections setTimeout(ensureBannerPresence, 1000); } }); // Monitor the entire document for comprehensive detection observer.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class', 'hidden'] }); window.navflowBannerObserver = observer; // Monitor for page navigation events that might remove the banner if (!window.originalPushState) { window.originalPushState = history.pushState; window.originalReplaceState = history.replaceState; history.pushState = function(...args) { window.originalPushState.apply(history, args); setTimeout(ensureBannerPresence, 200); }; history.replaceState = function(...args) { window.originalReplaceState.apply(history, args); setTimeout(ensureBannerPresence, 200); }; } // Monitor for popstate events (back/forward navigation) window.addEventListener('popstate', () => { setTimeout(ensureBannerPresence, 200); }); // Monitor for page visibility changes document.addEventListener('visibilitychange', () => { if (!document.hidden) { setTimeout(ensureBannerPresence, 100); } }); // Monitor for hashchange events window.addEventListener('hashchange', () => { setTimeout(ensureBannerPresence, 100); }); // Monitor for focus events window.addEventListener('focus', () => { setTimeout(ensureBannerPresence, 100); }); })(); `; await page.evaluate(monitorScript); } /** * Get the banner injection script as a string */ getBannerInjectionScript() { return `function(sessionKey, prompt, timeout, serverUrl, deviceApiKey) { // Prevent duplicate injections if (window.navflowInjecting) { console.log('⚠️ Banner injection already in progress, skipping...'); return; } window.navflowInjecting = true; try { // Remove any existing banners (including duplicates) const existingBanners = document.querySelectorAll('#navflow-human-loop-banner, [id^="navflow-human-loop-banner"]'); existingBanners.forEach(banner => banner.remove()); // Also remove any banners that might not have the exact ID const possibleBanners = document.querySelectorAll('[id*="navflow"], [class*="navflow"]'); possibleBanners.forEach(banner => { if (banner.textContent && banner.textContent.includes('Automation Paused')) { banner.remove(); } }); // Create banner HTML const bannerHTML = \` <div id="navflow-human-loop-banner" style=" position: fixed; top: 0; left: 0; right: 0; z-index: 999999; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 16px 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); border-bottom: 3px solid #4c51bf; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.5; user-select: none; "> <div style="display: flex; align-items: center; justify-content: space-between; max-width: 1200px; margin: 0 auto;"> <div style="display: flex; align-items: center; gap: 12px; flex: 1;"> <div style=" width: 32px; height: 32px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 16px; ">⏸️</div> <div> <div style="font-weight: 600; margin-bottom: 4px;">Automation Paused - Human Input Required</div> <div style="opacity: 0.9; font-size: 13px;">\${prompt}</div> </div> </div> <div style="display: flex; align-items: center; gap: 16px;"> <div style="display: flex; align-items: center; gap: 8px;"> <span style="font-size: 12px; opacity: 0.8;">Time remaining:</span> <span id="navflow-timer" style=" font-weight: 700; font-size: 16px; min-width: 50px; text-align: center; ">\${Math.floor(timeout / 60)}:\${(timeout % 60).toString().padStart(2, '0')}</span> </div> <div style="display: flex; gap: 8px;"> <button id="navflow-reset-btn" style=" background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); color: white; padding: 6px 12px; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.2s; ">Reset Timer</button> <button id="navflow-complete-btn" style=" background: #48bb78; border: 1px solid #38a169; color: white; padding: 6px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.2s; ">Complete</button> </div> </div> </div> </div> \`; // Insert banner at the top of the body document.body.insertAdjacentHTML('afterbegin', bannerHTML); // Add event listeners setTimeout(() => { const resetBtn = document.getElementById('navflow-reset-btn'); const completeBtn = document.getElementById('navflow-complete-btn'); if (resetBtn) { resetBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); window.navflowBannerAction = { type: 'reset', sessionKey: sessionKey, resetTimeout: timeout, timestamp: Date.now() }; window.dispatchEvent(new CustomEvent('navflow-banner-action', { detail: window.navflowBannerAction })); }); } if (completeBtn) { completeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); window.navflowBannerAction = { type: 'complete', sessionKey: sessionKey, userResponse: 'User completed task', timestamp: Date.now() }; window.dispatchEvent(new CustomEvent('navflow-banner-action', { detail: window.navflowBannerAction })); }); } }, 100); } finally { // Clear the injection flag setTimeout(() => { window.navflowInjecting = false; }, 500); } }`; } /** * Clean up all sessions */ async cleanup() { const sessionKeys = Array.from(this.activeSessions.keys()); await Promise.all(sessionKeys.map(key => this.hideBanner(key))); } } exports.HumanLoopBannerService = HumanLoopBannerService; //# sourceMappingURL=HumanLoopBannerService.js.map