374 lines
12 KiB
JavaScript
374 lines
12 KiB
JavaScript
/**
|
|
* 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();
|