/** * Application wiring. * * Flow: user text -> POST /api/robot/speak -> backend -> robot * robot lifecycle -> backend event bus -> WebSocket -> this file -> UI * * The POST returns as soon as the robot acknowledges the utterance; everything * after that ("speaking", "completed", failures) arrives over the socket. */ import { api, ApiError } from './api.js'; import { RobotSocket } from './socket.js'; import * as ui from './ui.js'; const DRAFT_KEY = 'agibot-a3:draft'; const RESET_DELAY = 2200; const state = { config: { maxLength: 1000, mode: 'mock' }, status: { state: 'connecting', connected: false }, history: [], audio: { items: [], stats: {} }, auditioning: null, activeRequestId: null, stage: 'idle', resetTimer: null, }; const socket = new RobotSocket(); // ========================================================================= // derived UI state // ========================================================================= function syncControls() { const length = ui.el.textInput.value.trim().length; const busy = Boolean(state.activeRequestId); const connected = Boolean(state.status.connected); ui.setControls({ canSpeak: connected && length > 0 && length <= state.config.maxLength, busy, canStop: busy, }); ui.renderCounter(ui.el.textInput.value.length, state.config.maxLength); } function applyStatus(status) { state.status = { ...state.status, ...status }; ui.renderConnection(state.status); ui.renderFacts(state.status); // The robot vanished mid-utterance: stop pretending it is still speaking. if (!state.status.connected && state.activeRequestId) { finishUtterance('error', 'Robot disconnected during speech.'); } // Page opened (or reloaded) while the robot is mid-sentence - adopt it rather // than showing "Ready" over a talking robot. if (state.status.busy && state.status.activeRequestId && !state.activeRequestId) { state.activeRequestId = state.status.activeRequestId; state.stage = 'speaking'; clearTimeout(state.resetTimer); ui.renderPipeline('speaking'); ui.renderLive('speaking', 'Speaking…'); } syncControls(); } // ========================================================================= // speech lifecycle // ========================================================================= const STAGE_VIEW = { queued: { tone: 'busy', text: 'Queued…' }, sending: { tone: 'busy', text: 'Sending to robot…' }, processing: { tone: 'busy', text: 'Robot is preparing speech…' }, speaking: { tone: 'speaking', text: 'Speaking…' }, completed: { tone: 'ok', text: 'Completed' }, cancelled: { tone: 'error', text: 'Stopped' }, failed: { tone: 'error', text: 'Speech failed' }, }; function onProgress(progress) { if (state.activeRequestId && progress.requestId !== state.activeRequestId) return; state.activeRequestId = progress.requestId; const stage = progress.stage; state.stage = stage; ui.renderPipeline(stage); const view = STAGE_VIEW[stage] || { tone: 'busy', text: stage }; const detail = progress.detail || (typeof progress.elapsedMs === 'number' ? `${progress.elapsedMs} ms` : ''); ui.renderLive(view.tone, progress.message || view.text, detail); if (['completed', 'failed', 'cancelled'].includes(stage)) { if (stage === 'completed') refreshAudio(); if (stage === 'failed') ui.toast(progress.message || 'Speech request failed.', 'error'); finishUtterance(view.tone, progress.message || view.text, detail); } syncControls(); } function finishUtterance(tone, message, detail = '') { state.activeRequestId = null; ui.renderLive(tone, message, detail); syncControls(); clearTimeout(state.resetTimer); state.resetTimer = setTimeout(() => { if (state.activeRequestId) return; // a new utterance already started state.stage = 'idle'; ui.renderPipeline('idle'); ui.renderLive('idle', state.status.connected ? 'Ready' : 'Robot offline'); }, RESET_DELAY); } // ========================================================================= // history // ========================================================================= function applyHistoryEvent(payload) { if (payload.action === 'clear') { state.history = []; } else if (payload.entry) { const index = state.history.findIndex((item) => item.id === payload.entry.id); if (index === -1) state.history.unshift(payload.entry); else state.history[index] = payload.entry; const limit = state.config.historyLimit || 100; if (state.history.length > limit) state.history.length = limit; } ui.renderHistory(state.history, useHistoryText, replayLine); } /** Speak a history line again. Its audio is saved, so this is instant. */ async function replayLine(item) { ui.el.textInput.value = item.text; saveDraft(); await speak(); } function useHistoryText(text) { ui.el.textInput.value = text; ui.el.textInput.focus(); ui.el.textInput.setSelectionRange(text.length, text.length); saveDraft(); syncControls(); } // ========================================================================= // actions // ========================================================================= async function speak() { const text = ui.el.textInput.value.trim(); if (!text) { ui.toast('Type something for the robot to say first.', 'error'); ui.el.textInput.focus(); return; } if (!state.status.connected) { ui.toast( state.status.error || 'Robot is offline. Check the robot IP and network connection.', 'error', ); return; } clearTimeout(state.resetTimer); ui.renderPipeline('sending'); ui.renderLive('busy', 'Sending to robot…'); ui.setControls({ canSpeak: false, busy: true, canStop: true }); try { const result = await api.speak(text); state.activeRequestId = result.requestId; syncControls(); } catch (err) { state.activeRequestId = null; const message = err instanceof ApiError ? err.message : 'Speech request failed.'; ui.toast(message, 'error'); ui.renderPipeline('failed'); finishUtterance('error', message); } } async function stop() { try { await api.stop(); ui.renderLive('error', 'Stopping…'); } catch (err) { ui.toast(err.message || 'Could not stop the robot.', 'error'); } } function clearText() { ui.el.textInput.value = ''; saveDraft(); ui.el.textInput.focus(); syncControls(); } // ========================================================================= // saved audio // ========================================================================= async function refreshAudio() { try { const data = await api.audio(); state.audio = { items: data.items || [], stats: data.stats || {} }; ui.renderAudio(state.audio.items, state.audio.stats, { fileUrl: api.audioUrl, onPlayState: (audio) => { // Only one clip auditions at a time. if (state.auditioning && state.auditioning !== audio) { state.auditioning.pause(); state.auditioning.currentTime = 0; } state.auditioning = audio; }, }); } catch { /* the panel is a convenience; never let it break the page */ } } async function clearAudio() { try { const result = await api.clearAudio(); ui.toast(`Deleted ${result.removed} saved clip(s).`, 'ok', 3000); await refreshAudio(); // Rows can no longer promise instant replay. state.history = state.history.map((item) => ({ ...item, audioSaved: false })); ui.renderHistory(state.history, useHistoryText, replayLine); } catch (err) { ui.toast(err.message || 'Could not clear saved audio.', 'error'); } } async function clearHistory() { try { await api.clearHistory(); state.history = []; ui.renderHistory(state.history, useHistoryText, replayLine); } catch (err) { ui.toast(err.message || 'Could not clear history.', 'error'); } } async function reconnect() { ui.el.reconnectBtn.dataset.spin = 'true'; try { await api.reconnect(); ui.toast('Reconnecting to the robot…', 'info', 2500); } catch (err) { ui.toast(err.message || 'Reconnect failed.', 'error'); } finally { setTimeout(() => { ui.el.reconnectBtn.dataset.spin = 'false'; }, 900); } } // ========================================================================= // draft persistence (per-browser convenience only) // ========================================================================= function saveDraft() { try { localStorage.setItem(DRAFT_KEY, ui.el.textInput.value); } catch { /* private mode */ } } function loadDraft() { try { const draft = localStorage.getItem(DRAFT_KEY); if (draft) ui.el.textInput.value = draft; } catch { /* ignore */ } } // ========================================================================= // events // ========================================================================= function bindDom() { ui.el.speakBtn.addEventListener('click', speak); ui.el.stopBtn.addEventListener('click', stop); ui.el.clearBtn.addEventListener('click', clearText); ui.el.clearHistoryBtn.addEventListener('click', clearHistory); ui.el.clearAudioBtn.addEventListener('click', clearAudio); ui.el.reconnectBtn.addEventListener('click', reconnect); ui.el.textInput.addEventListener('input', () => { saveDraft(); syncControls(); }); // Ctrl/Cmd+Enter speaks. A bare Enter inserts a newline, on purpose - nobody // wants a half-typed sentence going out of the robot's speaker mid-demo. ui.el.textInput.addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); speak(); } }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && state.activeRequestId) { event.preventDefault(); stop(); } }); } function bindSocket() { socket.addEventListener('hello', (event) => { const { config, status, history } = event.detail; state.config = { ...state.config, ...config }; ui.renderConfig(state.config); applyStatus(status); state.history = history || []; ui.renderHistory(state.history, useHistoryText, replayLine); refreshAudio(); document.documentElement.removeAttribute('data-loading'); }); socket.addEventListener('robot.status', (event) => applyStatus(event.detail)); socket.addEventListener('speech.progress', (event) => onProgress(event.detail)); socket.addEventListener('history.updated', (event) => applyHistoryEvent(event.detail)); socket.addEventListener('config.updated', (event) => { state.config = { ...state.config, ...event.detail }; ui.renderConfig(state.config); }); socket.addEventListener('rtt', (event) => { const rtt = event.detail.rtt; if (typeof rtt === 'number') { ui.el.latencyChip.title = `Robot round-trip. Browser to backend: ${rtt} ms`; } }); socket.addEventListener('link', (event) => { if (event.detail.up) return; // The backend went away - say so rather than leaving a stale green light. applyStatus({ state: 'disconnected', connected: false, error: 'Lost connection to the local server.', }); }); } // ========================================================================= // boot // ========================================================================= async function boot() { bindDom(); bindSocket(); loadDraft(); syncControls(); socket.connect(); // Fallback for the rare case the socket cannot open at all. setTimeout(async () => { if (socket.isOpen) return; try { const [config, status, history] = await Promise.all([ api.config(), api.status(), api.history(), ]); state.config = { ...state.config, ...config }; ui.renderConfig(state.config); applyStatus(status); state.history = history.items || []; ui.renderHistory(state.history, useHistoryText, replayLine); refreshAudio(); document.documentElement.removeAttribute('data-loading'); } catch { ui.toast('Cannot reach the local server. Is backend/main.py running?', 'error', 12000); } }, 1200); ui.el.textInput.focus(); } boot();