2026-09-03 00:10:18 +04:00

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();