From d9047517be1c11c0a263ef304852e88cf6e7a431 Mon Sep 17 00:00:00 2001 From: Kevin Russell Date: Thu, 20 Nov 2025 10:08:08 -0700 Subject: [PATCH 1/5] Improve Emma WebSocket failover --- js/emma-browser-client.js | 51 ++++++++++++-- js/ws-chat.js | 141 ++++++++++++++++++++++++++++++++------ 2 files changed, 165 insertions(+), 27 deletions(-) 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..8d739ae4 100644 --- a/js/ws-chat.js +++ b/js/ws-chat.js @@ -4,6 +4,16 @@ console.log('💬 Emma WS Chat: Initializing...'); // WebSocket state let ws; let isProcessing = false; +let reconnectTimer = null; +const RECONNECT_DELAY_MS = 2000; +const failedWsUrls = new Set(); +let lastKnownWsUrl = (() => { + try { + return sessionStorage.getItem('emma-last-ws-url'); + } catch (_) { + return null; + } +})(); // Initialize chat document.addEventListener('DOMContentLoaded', () => { @@ -15,33 +25,90 @@ 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 getWebSocketUrl() { + const fallbackUrls = [ + 'wss://emma-lite-optimized.onrender.com/voice', + 'wss://emma-hjjc.onrender.com/voice' + ]; + + const seen = new Set(); + const candidates = []; + + const pushCandidate = (url) => { + if (url && !seen.has(url) && !failedWsUrls.has(url)) { + seen.add(url); + candidates.push(url); } - ws.send(JSON.stringify({ type: 'start_session', sessionId })); }; - ws.onmessage = (event) => { - const message = JSON.parse(event.data); - handleServerMessage(message); - }; + pushCandidate(lastKnownWsUrl); - ws.onclose = () => { - console.log('💬 Emma WS Chat: Disconnected'); - // Optional: attempt to reconnect - }; + if (typeof window.getEmmaBackendWsUrl === 'function') { + try { + pushCandidate(window.getEmmaBackendWsUrl()); + } catch (e) { + console.warn('💬 Emma WS Chat: backend URL resolution failed', e); + } + } + + fallbackUrls.forEach(pushCandidate); + + return candidates; +} + +function connectWebSocket() { + clearTimeout(reconnectTimer); + + const candidates = getWebSocketUrl(); + + const tryCandidate = (index = 0) => { + const wsUrl = candidates[index]; + + if (!wsUrl) { + console.error('💬 Emma WS Chat: No WebSocket candidates available'); + scheduleReconnect(); + return; + } + + console.log('💬 Emma WS Chat: Connecting to', wsUrl); + ws = new WebSocket(wsUrl); - ws.onerror = (error) => { - console.error('💬 Emma WS Chat: Error', error); + 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); + } + try { sessionStorage.setItem('emma-last-ws-url', wsUrl); } catch (_) {} + lastKnownWsUrl = wsUrl; + failedWsUrls.delete(wsUrl); + ws.send(JSON.stringify({ type: 'start_session', sessionId })); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + handleServerMessage(message); + }; + + ws.onclose = () => { + console.log('💬 Emma WS Chat: Disconnected'); + scheduleReconnect(); + }; + + ws.onerror = (error) => { + console.error('💬 Emma WS Chat: Error', error); + failedWsUrls.add(wsUrl); + if (index + 1 < candidates.length) { + try { ws.close(); } catch (_) {} + tryCandidate(index + 1); + return; + } + scheduleReconnect(); + }; }; + + tryCandidate(0); } function handleServerMessage(message) { @@ -59,17 +126,31 @@ 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; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connectWebSocket(); + }, RECONNECT_DELAY_MS); +} + async function handleToolRequest(request) { const { call_id, tool_name, parameters } = request; let result; @@ -77,6 +158,9 @@ 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 }; @@ -136,6 +220,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'); From 9f7742902c38ba6e805ab4b3ce5066d9d2dc5c4d Mon Sep 17 00:00:00 2001 From: Kevin Russell Date: Thu, 20 Nov 2025 10:18:36 -0700 Subject: [PATCH 2/5] Make chat WebSocket URL resolution merge-safe --- js/ws-chat.js | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/js/ws-chat.js b/js/ws-chat.js index 8d739ae4..0c3ce882 100644 --- a/js/ws-chat.js +++ b/js/ws-chat.js @@ -26,9 +26,10 @@ document.addEventListener('DOMContentLoaded', () => { }); function getWebSocketUrl() { - const fallbackUrls = [ - 'wss://emma-lite-optimized.onrender.com/voice', - 'wss://emma-hjjc.onrender.com/voice' + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const fallbackHosts = [ + 'emma-lite-optimized.onrender.com', + 'emma-hjjc.onrender.com' ]; const seen = new Set(); @@ -41,17 +42,35 @@ function getWebSocketUrl() { } }; - pushCandidate(lastKnownWsUrl); + const buildWsUrl = (hostOrUrl) => { + if (!hostOrUrl) return null; + try { + const maybeUrl = new URL(hostOrUrl, window.location.href); + // If caller passed a full ws/wss URL, keep it as is + if (maybeUrl.protocol === 'ws:' || maybeUrl.protocol === 'wss:') { + return maybeUrl.toString(); + } + // Otherwise, assume host-like input + return `${protocol}//${hostOrUrl.replace(/^\/*/, '')}/voice`; + } catch (_) { + return null; + } + }; + + pushCandidate(buildWsUrl(lastKnownWsUrl)); + + // Prefer the current page host, which also keeps local development working. + pushCandidate(buildWsUrl(window.location.host)); if (typeof window.getEmmaBackendWsUrl === 'function') { try { - pushCandidate(window.getEmmaBackendWsUrl()); + pushCandidate(buildWsUrl(window.getEmmaBackendWsUrl())); } catch (e) { console.warn('💬 Emma WS Chat: backend URL resolution failed', e); } } - fallbackUrls.forEach(pushCandidate); + fallbackHosts.forEach((host) => pushCandidate(buildWsUrl(host))); return candidates; } From 808f82bd87db00824aa12e890b061888a103059f Mon Sep 17 00:00:00 2001 From: Kevin Russell Date: Fri, 21 Nov 2025 11:36:26 -0700 Subject: [PATCH 3/5] Add chat WebSocket override fallback --- js/ws-chat.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/js/ws-chat.js b/js/ws-chat.js index 0c3ce882..bb53c26b 100644 --- a/js/ws-chat.js +++ b/js/ws-chat.js @@ -46,12 +46,13 @@ function getWebSocketUrl() { if (!hostOrUrl) return null; try { const maybeUrl = new URL(hostOrUrl, window.location.href); - // If caller passed a full ws/wss URL, keep it as is if (maybeUrl.protocol === 'ws:' || maybeUrl.protocol === 'wss:') { return maybeUrl.toString(); } - // Otherwise, assume host-like input - return `${protocol}//${hostOrUrl.replace(/^\/*/, '')}/voice`; + + const host = maybeUrl.host || hostOrUrl; + const path = maybeUrl.pathname && maybeUrl.pathname !== '/' ? maybeUrl.pathname : '/voice'; + return `${protocol}//${host.replace(/^\/*/, '')}${path}`; } catch (_) { return null; } @@ -59,6 +60,11 @@ function getWebSocketUrl() { pushCandidate(buildWsUrl(lastKnownWsUrl)); + // Allow explicit overrides to avoid repeated merge conflicts across environments. + if (window.EMMA_WS_URL) { + pushCandidate(buildWsUrl(window.EMMA_WS_URL)); + } + // Prefer the current page host, which also keeps local development working. pushCandidate(buildWsUrl(window.location.host)); From dbd9657ba30c11c46e3a53d6d6c594e93ff262d7 Mon Sep 17 00:00:00 2001 From: Kevin Russell Date: Fri, 21 Nov 2025 11:36:33 -0700 Subject: [PATCH 4/5] Improve chat connection resilience and offline fallback --- chat.html | 27 +++++++++++++++++ js/ws-chat.js | 82 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 2 deletions(-) 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/ws-chat.js b/js/ws-chat.js index bb53c26b..e36d6960 100644 --- a/js/ws-chat.js +++ b/js/ws-chat.js @@ -14,10 +14,17 @@ let lastKnownWsUrl = (() => { 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'); @@ -25,6 +32,20 @@ document.addEventListener('DOMContentLoaded', () => { chatInput.focus(); }); +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 = [ @@ -81,16 +102,61 @@ function getWebSocketUrl() { 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) 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); const candidates = getWebSocketUrl(); + updateConnectionStatus('connecting', 'Connecting to Emma…'); const tryCandidate = (index = 0) => { const wsUrl = candidates[index]; if (!wsUrl) { console.error('💬 Emma WS Chat: No WebSocket candidates available'); + updateConnectionStatus('offline', 'Unable to find an Emma server. Retrying…'); scheduleReconnect(); return; } @@ -100,6 +166,7 @@ function connectWebSocket() { ws.onopen = () => { console.log('💬 Emma WS Chat: Connected'); + updateConnectionStatus('connected', 'Connected to Emma'); let sessionId = sessionStorage.getItem('emma-session-id'); if (!sessionId) { sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -109,6 +176,7 @@ function connectWebSocket() { lastKnownWsUrl = wsUrl; failedWsUrls.delete(wsUrl); ws.send(JSON.stringify({ type: 'start_session', sessionId })); + flushPendingMessages(); }; ws.onmessage = (event) => { @@ -118,11 +186,13 @@ function connectWebSocket() { ws.onclose = () => { console.log('💬 Emma WS Chat: Disconnected'); + updateConnectionStatus('offline', 'Connection lost. Reconnecting…'); scheduleReconnect(); }; 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 (_) {} @@ -170,6 +240,7 @@ function handleServerMessage(message) { function scheduleReconnect() { if (reconnectTimer) return; + updateConnectionStatus('connecting', 'Reconnecting to Emma…'); reconnectTimer = setTimeout(() => { reconnectTimer = null; connectWebSocket(); @@ -202,7 +273,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 }); @@ -210,7 +281,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) { From 616889bf7c77020ef2f9ac4bab9eeefbd2414fd2 Mon Sep 17 00:00:00 2001 From: Kevin Russell Date: Fri, 21 Nov 2025 12:29:12 -0700 Subject: [PATCH 5/5] Harden chat WebSocket recovery and fallback handling --- js/ws-chat.js | 79 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/js/ws-chat.js b/js/ws-chat.js index e36d6960..9222e951 100644 --- a/js/ws-chat.js +++ b/js/ws-chat.js @@ -5,7 +5,9 @@ console.log('💬 Emma WS Chat: Initializing...'); let ws; let isProcessing = false; let reconnectTimer = null; -const RECONNECT_DELAY_MS = 2000; +let reconnectAttempts = 0; +const RECONNECT_BASE_DELAY_MS = 2000; +const RECONNECT_MAX_DELAY_MS = 15000; const failedWsUrls = new Set(); let lastKnownWsUrl = (() => { try { @@ -56,48 +58,47 @@ function getWebSocketUrl() { const seen = new Set(); const candidates = []; - const pushCandidate = (url) => { - if (url && !seen.has(url) && !failedWsUrls.has(url)) { - seen.add(url); - candidates.push(url); - } - }; - - const buildWsUrl = (hostOrUrl) => { + const normalizeUrl = (hostOrUrl) => { if (!hostOrUrl) return null; try { const maybeUrl = new URL(hostOrUrl, window.location.href); - if (maybeUrl.protocol === 'ws:' || maybeUrl.protocol === 'wss:') { - return maybeUrl.toString(); - } - + const isWs = maybeUrl.protocol === 'ws:' || maybeUrl.protocol === 'wss:'; const host = maybeUrl.host || hostOrUrl; const path = maybeUrl.pathname && maybeUrl.pathname !== '/' ? maybeUrl.pathname : '/voice'; - return `${protocol}//${host.replace(/^\/*/, '')}${path}`; + const base = isWs ? maybeUrl.protocol : protocol; + return `${base}//${host.replace(/^\/*/, '')}${path}`; } catch (_) { return null; } }; - pushCandidate(buildWsUrl(lastKnownWsUrl)); + const pushCandidate = (url, reason) => { + if (!url) return; + if (failedWsUrls.has(url)) return; + if (seen.has(url)) return; + seen.add(url); + candidates.push({ url, reason }); + }; + + pushCandidate(normalizeUrl(lastKnownWsUrl), 'last-known'); // Allow explicit overrides to avoid repeated merge conflicts across environments. if (window.EMMA_WS_URL) { - pushCandidate(buildWsUrl(window.EMMA_WS_URL)); + pushCandidate(normalizeUrl(window.EMMA_WS_URL), 'override'); } // Prefer the current page host, which also keeps local development working. - pushCandidate(buildWsUrl(window.location.host)); + pushCandidate(normalizeUrl(window.location.host), 'page-host'); if (typeof window.getEmmaBackendWsUrl === 'function') { try { - pushCandidate(buildWsUrl(window.getEmmaBackendWsUrl())); + pushCandidate(normalizeUrl(window.getEmmaBackendWsUrl()), 'backend'); } catch (e) { console.warn('💬 Emma WS Chat: backend URL resolution failed', e); } } - fallbackHosts.forEach((host) => pushCandidate(buildWsUrl(host))); + fallbackHosts.forEach((host) => pushCandidate(normalizeUrl(host), 'fallback')); return candidates; } @@ -130,7 +131,10 @@ function flushPendingMessages() { } async function respondWithFallback(userMessage) { - if (!fallbackIntelligence) return; + 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); @@ -147,12 +151,18 @@ async function respondWithFallback(userMessage) { 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 wsUrl = candidates[index]; + const candidate = candidates[index]; + const wsUrl = candidate?.url; if (!wsUrl) { console.error('💬 Emma WS Chat: No WebSocket candidates available'); @@ -161,12 +171,16 @@ function connectWebSocket() { return; } - console.log('💬 Emma WS Chat: Connecting to', wsUrl); + 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'); - updateConnectionStatus('connected', 'Connected to Emma'); + 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)}`; @@ -180,8 +194,12 @@ function connectWebSocket() { }; ws.onmessage = (event) => { - const message = JSON.parse(event.data); - handleServerMessage(message); + try { + const message = JSON.parse(event.data); + handleServerMessage(message); + } catch (err) { + console.warn('💬 Emma WS Chat: Failed to parse server message', err); + } }; ws.onclose = () => { @@ -240,11 +258,13 @@ function handleServerMessage(message) { function scheduleReconnect() { if (reconnectTimer) return; - updateConnectionStatus('connecting', 'Reconnecting to Emma…'); + 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(); - }, RECONNECT_DELAY_MS); + }, delay); } async function handleToolRequest(request) { @@ -262,6 +282,11 @@ async function handleToolRequest(request) { 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,