diff --git a/chat.html b/chat.html index ce116899..fce3d049 100644 --- a/chat.html +++ b/chat.html @@ -15,6 +15,7 @@ +
+
Connecting to Emma…

Welcome to Emma Chat

diff --git a/js/emma-browser-client.js b/js/emma-browser-client.js index 159fdaf6..466ff92a 100644 --- a/js/emma-browser-client.js +++ b/js/emma-browser-client.js @@ -29,6 +29,16 @@ class EmmaBrowserClient { : null; this._lastSentApiKey = undefined; this.voicePlaybackEnabled = true; + this.socketGeneration = 0; + this.heartbeatInterval = null; + this.failedWsUrls = new Set(); + this.lastKnownWsUrl = (() => { + try { + return sessionStorage.getItem('emma-last-ws-url'); + } catch (_) { + return null; + } + })(); console.log('🎙️ Emma Browser Client initialized'); @@ -223,12 +233,15 @@ class EmmaBrowserClient { const wsCandidates = []; const pushCandidate = (url) => { - if (url && !seen.has(url)) { + if (url && !seen.has(url) && !this.failedWsUrls.has(url)) { seen.add(url); wsCandidates.push(url); } }; + // Sticky preference for last known good URL + pushCandidate(this.lastKnownWsUrl); + // Preferred runtime override pushCandidate(options.wsUrl); @@ -273,9 +286,11 @@ class EmmaBrowserClient { try { await this._attemptWebSocketConnection(candidate); + try { sessionStorage.setItem('emma-last-ws-url', candidate); } catch (_) {} return; // Successful connection } catch (error) { lastError = error; + this.failedWsUrls.add(candidate); console.warn(`⚠️ WebSocket connect failed for ${candidate}:`, error?.message || error); } } @@ -290,6 +305,7 @@ class EmmaBrowserClient { try { const websocket = new WebSocket(wsUrl); let resolved = false; + const socketId = ++this.socketGeneration; const cleanup = (err) => { if (resolved) return; @@ -312,7 +328,8 @@ class EmmaBrowserClient { } this.websocket = websocket; - this.setupWebSocketHandlers(); + this.setupWebSocketHandlers(websocket, socketId); + this.startHeartbeat(); console.log('✅ Connected to Emma agent', wsUrl); this.isConnected = true; @@ -459,8 +476,11 @@ class EmmaBrowserClient { /** * Setup WebSocket event handlers */ - setupWebSocketHandlers() { - this.websocket.onmessage = async (event) => { + setupWebSocketHandlers(websocket, socketId) { + const isStaleSocket = () => socketId !== this.socketGeneration; + + websocket.onmessage = async (event) => { + if (isStaleSocket()) return; try { const message = JSON.parse(event.data); console.log('📨 Emma message:', message.type); @@ -566,12 +586,15 @@ class EmmaBrowserClient { } }; - this.websocket.onerror = (error) => { + websocket.onerror = (error) => { + if (isStaleSocket()) return; console.error('❌ WebSocket error:', error); this.showError('Connection error', 'Lost connection to Emma'); }; - this.websocket.onclose = () => { + websocket.onclose = () => { + if (isStaleSocket()) return; + this.stopHeartbeat(); console.log('🔇 Emma agent connection closed'); this.isConnected = false; this.setState('idle'); @@ -581,6 +604,22 @@ class EmmaBrowserClient { }; } + startHeartbeat() { + this.stopHeartbeat(); + this.heartbeatInterval = setInterval(() => { + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { + this.sendToAgent({ type: 'ping', timestamp: Date.now() }); + } + }, 15000); + } + + stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + /** * Handle tool requests from Emma agent (privacy-first execution) */ diff --git a/js/ws-chat.js b/js/ws-chat.js index a3cf51da..9222e951 100644 --- a/js/ws-chat.js +++ b/js/ws-chat.js @@ -4,10 +4,29 @@ console.log('💬 Emma WS Chat: Initializing...'); // WebSocket state let ws; let isProcessing = false; +let reconnectTimer = null; +let reconnectAttempts = 0; +const RECONNECT_BASE_DELAY_MS = 2000; +const RECONNECT_MAX_DELAY_MS = 15000; +const failedWsUrls = new Set(); +let lastKnownWsUrl = (() => { + try { + return sessionStorage.getItem('emma-last-ws-url'); + } catch (_) { + return null; + } +})(); +let connectionStatusEl; +let connectionState = 'connecting'; +let pendingMessages = []; +let fallbackIntelligence = null; // Initialize chat document.addEventListener('DOMContentLoaded', () => { console.log('💬 Emma WS Chat: DOM loaded'); + connectionStatusEl = document.getElementById('chat-connection-status'); + initializeFallbackIntelligence(); + updateConnectionStatus('connecting', 'Connecting to Emma…'); connectWebSocket(); const chatInput = document.getElementById('chat-input'); @@ -15,33 +34,194 @@ document.addEventListener('DOMContentLoaded', () => { chatInput.focus(); }); -function connectWebSocket() { - const wsUrl = `ws://${window.location.host}/voice`; - ws = new WebSocket(wsUrl); - - ws.onopen = () => { - console.log('💬 Emma WS Chat: Connected'); - let sessionId = sessionStorage.getItem('emma-session-id'); - if (!sessionId) { - sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - sessionStorage.setItem('emma-session-id', sessionId); +function initializeFallbackIntelligence() { + if (typeof EmmaUnifiedIntelligence !== 'function') { + console.warn('💬 Emma WS Chat: Unified intelligence unavailable; offline responses disabled.'); + return; + } + + fallbackIntelligence = new EmmaUnifiedIntelligence({ + dementiaMode: true, + validationTherapy: true, + apiKey: window.EMMA_OPENAI_KEY || null, + vaultAccess: () => window.emmaWebVault?.vaultData?.content + }); +} + +function getWebSocketUrl() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const fallbackHosts = [ + 'emma-lite-optimized.onrender.com', + 'emma-hjjc.onrender.com' + ]; + + const seen = new Set(); + const candidates = []; + + const normalizeUrl = (hostOrUrl) => { + if (!hostOrUrl) return null; + try { + const maybeUrl = new URL(hostOrUrl, window.location.href); + const isWs = maybeUrl.protocol === 'ws:' || maybeUrl.protocol === 'wss:'; + const host = maybeUrl.host || hostOrUrl; + const path = maybeUrl.pathname && maybeUrl.pathname !== '/' ? maybeUrl.pathname : '/voice'; + const base = isWs ? maybeUrl.protocol : protocol; + return `${base}//${host.replace(/^\/*/, '')}${path}`; + } catch (_) { + return null; } - ws.send(JSON.stringify({ type: 'start_session', sessionId })); }; - ws.onmessage = (event) => { - const message = JSON.parse(event.data); - handleServerMessage(message); + const pushCandidate = (url, reason) => { + if (!url) return; + if (failedWsUrls.has(url)) return; + if (seen.has(url)) return; + seen.add(url); + candidates.push({ url, reason }); }; - ws.onclose = () => { - console.log('💬 Emma WS Chat: Disconnected'); - // Optional: attempt to reconnect - }; + pushCandidate(normalizeUrl(lastKnownWsUrl), 'last-known'); + + // Allow explicit overrides to avoid repeated merge conflicts across environments. + if (window.EMMA_WS_URL) { + pushCandidate(normalizeUrl(window.EMMA_WS_URL), 'override'); + } + + // Prefer the current page host, which also keeps local development working. + pushCandidate(normalizeUrl(window.location.host), 'page-host'); + + if (typeof window.getEmmaBackendWsUrl === 'function') { + try { + pushCandidate(normalizeUrl(window.getEmmaBackendWsUrl()), 'backend'); + } catch (e) { + console.warn('💬 Emma WS Chat: backend URL resolution failed', e); + } + } + + fallbackHosts.forEach((host) => pushCandidate(normalizeUrl(host), 'fallback')); + + return candidates; +} + +function updateConnectionStatus(state, label) { + connectionState = state; + + if (connectionStatusEl) { + connectionStatusEl.textContent = label; + connectionStatusEl.classList.remove('connected', 'offline', 'connecting'); + connectionStatusEl.classList.add(state); + } + + const chatInput = document.getElementById('chat-input'); + const sendButton = document.querySelector('.chat-input-area button'); + const disable = state === 'connecting'; + if (chatInput) chatInput.disabled = disable; + if (sendButton) sendButton.disabled = disable; +} + +function flushPendingMessages() { + if (!pendingMessages.length || !ws || ws.readyState !== WebSocket.OPEN) return; + + const queue = [...pendingMessages]; + pendingMessages = []; + + queue.forEach((text) => { + ws.send(JSON.stringify({ type: 'user_text', text })); + }); +} + +async function respondWithFallback(userMessage) { + if (!fallbackIntelligence) { + displayMessage('system', 'Emma is offline right now. Your message will be retried when she reconnects.'); + return; + } + + try { + const response = await fallbackIntelligence.analyzeAndRespond(userMessage, null); + if (response?.text) { + displayMessage('emma', response.text); + } else { + displayMessage('system', 'Emma is offline but keeping notes.'); + } + } catch (error) { + console.warn('💬 Emma WS Chat: Fallback response failed', error); + displayMessage('system', 'Emma is offline right now. Your message has been saved.'); + } +} + +function connectWebSocket() { + clearTimeout(reconnectTimer); + reconnectTimer = null; + + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + try { ws.close(); } catch (_) {} + } + + const candidates = getWebSocketUrl(); + updateConnectionStatus('connecting', 'Connecting to Emma…'); + + const tryCandidate = (index = 0) => { + const candidate = candidates[index]; + const wsUrl = candidate?.url; + + if (!wsUrl) { + console.error('💬 Emma WS Chat: No WebSocket candidates available'); + updateConnectionStatus('offline', 'Unable to find an Emma server. Retrying…'); + scheduleReconnect(); + return; + } + + console.log('💬 Emma WS Chat: Connecting to', wsUrl, candidate?.reason ? `(${candidate.reason})` : ''); + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + const hostLabel = (() => { + try { return new URL(wsUrl).host || wsUrl; } catch (_) { return wsUrl; } + })(); + console.log('💬 Emma WS Chat: Connected'); + reconnectAttempts = 0; + updateConnectionStatus('connected', `Connected to Emma (${hostLabel})`); + let sessionId = sessionStorage.getItem('emma-session-id'); + if (!sessionId) { + sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + sessionStorage.setItem('emma-session-id', sessionId); + } + try { sessionStorage.setItem('emma-last-ws-url', wsUrl); } catch (_) {} + lastKnownWsUrl = wsUrl; + failedWsUrls.delete(wsUrl); + ws.send(JSON.stringify({ type: 'start_session', sessionId })); + flushPendingMessages(); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + handleServerMessage(message); + } catch (err) { + console.warn('💬 Emma WS Chat: Failed to parse server message', err); + } + }; + + ws.onclose = () => { + console.log('💬 Emma WS Chat: Disconnected'); + updateConnectionStatus('offline', 'Connection lost. Reconnecting…'); + scheduleReconnect(); + }; - ws.onerror = (error) => { - console.error('💬 Emma WS Chat: Error', error); + ws.onerror = (error) => { + console.error('💬 Emma WS Chat: Error', error); + updateConnectionStatus('offline', 'Unable to reach Emma. Trying next server…'); + failedWsUrls.add(wsUrl); + if (index + 1 < candidates.length) { + try { ws.close(); } catch (_) {} + tryCandidate(index + 1); + return; + } + scheduleReconnect(); + }; }; + + tryCandidate(0); } function handleServerMessage(message) { @@ -59,17 +239,34 @@ function handleServerMessage(message) { case 'emma_transcription': displayMessage('emma', message.transcript, Date.now(), true); break; + case 'emma_audio': + playAudioResponse(message); + break; case 'tool_request': handleToolRequest(message); break; case 'session_ended': console.log('Session ended'); break; + case 'error': + displayMessage('system', `❌ ${message.message || 'Emma ran into an issue.'}`); + break; default: console.warn('Unhandled server message type:', message.type); } } +function scheduleReconnect() { + if (reconnectTimer) return; + const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_DELAY_MS); + reconnectAttempts += 1; + updateConnectionStatus('connecting', `Reconnecting to Emma (retry in ${Math.round(delay / 1000)}s)…`); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connectWebSocket(); + }, delay); +} + async function handleToolRequest(request) { const { call_id, tool_name, parameters } = request; let result; @@ -77,11 +274,19 @@ async function handleToolRequest(request) { try { // For now, we'll just use the vault service directly on the client-side // This is a temporary solution until the architecture is unified + if (!window.emmaWebVault) { + throw new Error('Emma vault not initialized in browser'); + } result = await window.emmaWebVault.executeTool(tool_name, parameters); } catch (error) { result = { error: error.message }; } + if (!ws || ws.readyState !== WebSocket.OPEN) { + console.warn('💬 Emma WS Chat: Skipping tool_result, socket not open'); + return; + } + ws.send(JSON.stringify({ type: 'tool_result', call_id, @@ -93,7 +298,7 @@ async function sendMessage() { const input = document.getElementById('chat-input'); const message = input.value.trim(); - if (!message || isProcessing || !ws || ws.readyState !== WebSocket.OPEN) return; + if (!message || isProcessing) return; input.value = ''; autoResizeTextarea({ target: input }); @@ -101,7 +306,14 @@ async function sendMessage() { document.getElementById('welcome-message').style.display = 'none'; displayMessage('user', message); - ws.send(JSON.stringify({ type: 'user_text', text: message })); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'user_text', text: message })); + } else { + pendingMessages.push(message); + updateConnectionStatus('offline', 'Offline — using local Emma until reconnection'); + respondWithFallback(message); + scheduleReconnect(); + } } function displayMessage(sender, content, timestamp = Date.now(), animate = true) { @@ -136,6 +348,21 @@ function displayMessage(sender, content, timestamp = Date.now(), animate = true) messagesContainer.scrollTop = messagesContainer.scrollHeight; } +async function playAudioResponse(message) { + if (!message?.audio || message.encoding !== 'base64/mp3') { + return; + } + + try { + const audioUrl = `data:audio/mp3;base64,${message.audio}`; + const audio = new Audio(audioUrl); + audio.volume = 0.9; + await audio.play(); + } catch (error) { + console.warn('Failed to play Emma audio:', error); + } +} + function showTypingIndicator() { document.getElementById('typing-indicator').classList.add('active'); const messagesContainer = document.getElementById('chat-messages');