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

368 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** DOM rendering. All element lookups and mutations live here. */
const $ = (id) => document.getElementById(id);
export const el = {
modeBadge: $('modeBadge'),
connPill: $('connPill'),
connLabel: $('connLabel'),
latencyChip: $('latencyChip'),
latencyValue: $('latencyValue'),
reconnectBtn: $('reconnectBtn'),
banner: $('configBanner'),
bannerTitle: $('bannerTitle'),
bannerText: $('bannerText'),
textInput: $('textInput'),
counter: $('counter'),
counterNow: $('counterNow'),
counterMax: $('counterMax'),
speakBtn: $('speakBtn'),
speakLabel: $('speakLabel'),
stopBtn: $('stopBtn'),
clearBtn: $('clearBtn'),
pipeline: $('pipeline'),
pipelineFill: $('pipelineFill'),
live: $('live'),
liveText: $('liveText'),
liveDetail: $('liveDetail'),
factState: $('factState'),
factMode: $('factMode'),
factTransport: $('factTransport'),
factAddress: $('factAddress'),
factLatency: $('factLatency'),
factUptime: $('factUptime'),
statusNote: $('statusNote'),
audioList: $('audioList'),
audioCount: $('audioCount'),
audioNote: $('audioNote'),
clearAudioBtn: $('clearAudioBtn'),
historyList: $('historyList'),
historyEmpty: $('historyEmpty'),
historyCount: $('historyCount'),
clearHistoryBtn: $('clearHistoryBtn'),
toasts: $('toasts'),
};
const CONNECTION_LABEL = {
connected: 'Robot Connected',
connecting: 'Connecting…',
disconnected: 'Robot Disconnected',
error: 'Connection Error',
};
const PIPELINE_PROGRESS = {
idle: 0, queued: 8, sending: 26, processing: 52, speaking: 80,
completed: 100, failed: 100, cancelled: 100,
};
const STEP_ORDER = ['sending', 'processing', 'speaking', 'completed'];
// -------------------------------------------------------------------------
// header / connection
// -------------------------------------------------------------------------
export function renderConnection(status) {
const state = status.state || 'disconnected';
el.connPill.dataset.state = state;
el.connLabel.textContent = CONNECTION_LABEL[state] || state;
const latency = status.latencyMs;
if (typeof latency === 'number' && state === 'connected') {
el.latencyChip.hidden = false;
el.latencyValue.textContent = `${latency < 10 ? latency.toFixed(1) : Math.round(latency)} ms`;
} else {
el.latencyChip.hidden = true;
}
}
export function renderConfig(config) {
if (!config) return;
const mode = (config.mode || 'mock').toLowerCase();
el.modeBadge.textContent = mode === 'real' ? 'Live Robot' : 'Mock Mode';
el.modeBadge.dataset.mode = mode;
const max = config.maxLength || 1000;
el.counterMax.textContent = String(max);
el.textInput.maxLength = max;
const issues = config.issues || [];
if (issues.length) {
const worst = issues.find((i) => i.level === 'error') || issues[0];
el.banner.hidden = false;
el.banner.dataset.level = worst.level;
el.bannerTitle.textContent =
worst.level === 'error' ? 'Configuration error' : 'Configuration warning';
el.bannerText.textContent = `${worst.message} (${worst.key} in ${config.envFile || '.env'})`;
} else {
el.banner.hidden = true;
}
}
export function renderFacts(status) {
const robot = status.robot || {};
const state = status.state || 'disconnected';
el.factState.textContent = CONNECTION_LABEL[state] || state;
el.factState.dataset.tone =
state === 'connected' ? 'ok' : state === 'connecting' ? 'warn' : 'error';
el.factMode.textContent = robot.mode === 'real' ? 'Live robot' : 'Simulation';
el.factTransport.textContent = (robot.transport || '').toUpperCase();
el.factAddress.textContent = robot.address || '';
el.factAddress.title = robot.address || '';
el.factLatency.textContent =
typeof status.latencyMs === 'number' ? `${Math.round(status.latencyMs)} ms` : '';
el.factUptime.textContent =
typeof status.uptimeSeconds === 'number' ? formatDuration(status.uptimeSeconds) : '';
if (status.error) {
el.statusNote.hidden = false;
el.statusNote.textContent = status.error;
} else {
el.statusNote.hidden = true;
}
}
// -------------------------------------------------------------------------
// composer
// -------------------------------------------------------------------------
export function renderCounter(length, max) {
el.counterNow.textContent = String(length);
const ratio = max ? length / max : 0;
el.counter.dataset.level = ratio >= 1 ? 'over' : ratio > 0.85 ? 'warn' : 'ok';
}
export function setControls({ canSpeak, busy, canStop }) {
el.speakBtn.disabled = !canSpeak;
el.speakBtn.dataset.busy = busy ? 'true' : 'false';
el.speakLabel.textContent = busy ? 'Speaking…' : 'Speak';
el.stopBtn.disabled = !canStop;
}
// -------------------------------------------------------------------------
// pipeline + live status
// -------------------------------------------------------------------------
export function renderPipeline(stage) {
const key = PIPELINE_PROGRESS[stage] === undefined ? 'idle' : stage;
el.pipeline.dataset.stage = key;
el.pipelineFill.style.inset = `0 ${100 - PIPELINE_PROGRESS[key]}% 0 0`;
const activeIndex = STEP_ORDER.indexOf(
key === 'queued' ? 'sending' : key === 'failed' || key === 'cancelled' ? 'speaking' : key,
);
el.pipeline.querySelectorAll('li').forEach((li, index) => {
if (key === 'idle') { li.removeAttribute('data-on'); return; }
if (index < activeIndex) li.dataset.on = 'done';
else if (index === activeIndex) li.dataset.on = 'active';
else li.removeAttribute('data-on');
});
}
export function renderLive(tone, text, detail = '') {
el.live.dataset.tone = tone;
el.liveText.textContent = text;
el.liveDetail.textContent = detail || '';
}
// -------------------------------------------------------------------------
// history
// -------------------------------------------------------------------------
export function renderHistory(items, onPick, onReplay = () => {}) {
el.historyCount.textContent = String(items.length);
el.historyEmpty.hidden = items.length > 0;
el.historyList.innerHTML = '';
for (const item of items) {
const li = document.createElement('li');
li.className = 'hitem';
li.dataset.ok = item.success === null || item.success === undefined
? (isTerminal(item.stage) ? 'false' : 'pending')
: String(Boolean(item.success));
li.title = 'Click to put this text back in the box';
li.tabIndex = 0;
const bar = document.createElement('span');
bar.className = 'hitem__bar';
const body = document.createElement('div');
body.className = 'hitem__body';
const text = document.createElement('div');
text.className = 'hitem__text';
text.textContent = item.text;
const meta = document.createElement('div');
meta.className = 'hitem__meta';
meta.append(spanOf(formatTime(item.at)));
if (typeof item.ackLatencyMs === 'number') meta.append(spanOf(`${item.ackLatencyMs} ms`));
if (item.error) {
const err = spanOf(item.error);
err.className = 'err';
meta.append(err);
} else if (!isTerminal(item.stage)) {
meta.append(spanOf(item.stage));
}
body.append(text, meta);
li.append(bar, body);
if (item.audioSaved) {
const replay = document.createElement('button');
replay.className = 'hitem__replay';
replay.title = 'Play the saved audio instantly';
replay.setAttribute('aria-label', 'Replay this line');
replay.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
replay.addEventListener('click', (event) => {
event.stopPropagation(); // do not also load it into the box
onReplay(item);
});
li.append(replay);
}
const pick = () => onPick(item.text);
li.addEventListener('click', pick);
li.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); pick(); }
});
el.historyList.append(li);
}
}
function isTerminal(stage) {
return ['completed', 'failed', 'cancelled'].includes(stage);
}
function spanOf(text) {
const span = document.createElement('span');
span.textContent = text;
return span;
}
// -------------------------------------------------------------------------
// toasts
// -------------------------------------------------------------------------
export function toast(message, tone = 'info', ttl = 5000) {
const node = document.createElement('div');
node.className = 'toast';
node.dataset.tone = tone;
const dot = document.createElement('span');
dot.className = 'toast__dot';
const text = document.createElement('div');
text.textContent = message;
node.append(dot, text);
el.toasts.append(node);
const remove = () => {
node.classList.add('is-out');
setTimeout(() => node.remove(), 220);
};
const timer = setTimeout(remove, ttl);
node.addEventListener('click', () => { clearTimeout(timer); remove(); });
return node;
}
// -------------------------------------------------------------------------
// helpers
// -------------------------------------------------------------------------
export function formatTime(epochSeconds) {
if (!epochSeconds) return '';
return new Date(epochSeconds * 1000).toLocaleTimeString([], {
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
export function formatDuration(seconds) {
const total = Math.max(0, Math.round(seconds));
if (total < 60) return `${total}s`;
const minutes = Math.floor(total / 60);
if (minutes < 60) return `${minutes}m ${total % 60}s`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
// -------------------------------------------------------------------------
// saved audio
// -------------------------------------------------------------------------
const PLAY_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
const STOP_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
const DOWNLOAD_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/>' +
'<path d="m7 12 5 5 5-5"/><path d="M5 21h14"/></svg>';
export function renderAudio(items, stats, { fileUrl, onPlayState }) {
el.audioCount.textContent = String(items.length);
el.audioList.innerHTML = '';
if (!items.length) {
el.audioNote.textContent =
'Nothing saved yet. Speak a line with the neural voice and its audio is ' +
'kept here as a .wav, so it replays instantly with no synthesis.';
return;
}
// Keep the note to one short line; the full path would overflow the panel,
// so it goes in the tooltip instead.
el.audioNote.textContent =
`${items.length} clip(s) · ${(stats.seconds || 0).toFixed(0)}s · ` +
`${((stats.bytes || 0) / 1048576).toFixed(1)} MB · audio_library/`;
el.audioNote.title = stats.dir || '';
for (const item of items) {
const li = document.createElement('li');
li.className = 'aitem';
const button = document.createElement('button');
button.className = 'aitem__play';
button.innerHTML = PLAY_ICON;
button.title = 'Play in this browser';
// Played in the page, not through the backend: this is you auditioning a
// saved clip, not the robot speaking. Keeping them separate means the
// dashboard's Speaking state never lies.
const audio = new Audio(fileUrl(item.id));
button.addEventListener('click', () => {
if (!audio.paused) { audio.pause(); audio.currentTime = 0; return; }
onPlayState(audio);
audio.play().catch(() => {});
});
audio.addEventListener('play', () => { button.dataset.playing = 'true'; button.innerHTML = STOP_ICON; });
const reset = () => { button.dataset.playing = 'false'; button.innerHTML = PLAY_ICON; };
audio.addEventListener('pause', reset);
audio.addEventListener('ended', reset);
const body = document.createElement('div');
body.className = 'aitem__body';
const text = document.createElement('div');
text.className = 'aitem__text';
text.textContent = item.text;
text.title = item.text;
const meta = document.createElement('div');
meta.className = 'aitem__meta';
meta.textContent = `${(item.durationSeconds || 0).toFixed(1)}s · ${item.voice} · ${item.file}`;
meta.title = item.file;
body.append(text, meta);
const download = document.createElement('a');
download.className = 'aitem__dl';
download.href = fileUrl(item.id);
download.download = item.file;
download.title = 'Download this .wav';
download.innerHTML = DOWNLOAD_ICON;
li.append(button, body, download);
el.audioList.append(li);
}
}