diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..1a36543 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,111 @@ +(function () { + 'use strict'; + + // ── DOM refs ────────────────────────────────────────────────────────────── + const body = document.body; + const wsDot = document.getElementById('ws-dot'); + const stateLabel = document.getElementById('state-label'); + const progressFill = document.getElementById('progress-fill'); + const progressLabel = document.getElementById('progress-label'); + const svModel = document.getElementById('sv-model'); + const svState = document.getElementById('sv-state'); + const svPrompt = document.getElementById('sv-prompt'); + const svSpeed = document.getElementById('sv-speed'); + const svTokens = document.getElementById('sv-tokens'); + const svLoadStage = document.getElementById('sv-load-stage'); + const svReqs = document.getElementById('sv-reqs'); + + // ── State class management ──────────────────────────────────────────────── + const ALL_STATES = [ + 'offline','starting','loading','warming_up','idle', + 'sleeping','waiting','processing_prompt','generating','unloading' + ]; + + function applyState(stateName) { + ALL_STATES.forEach(s => body.classList.remove('state-' + s)); + body.classList.add('state-' + (ALL_STATES.includes(stateName) ? stateName : 'offline')); + } + + // ── DOM update ──────────────────────────────────────────────────────────── + function fmt(val, unit) { + return val != null ? val.toFixed(1) + ' ' + unit : '—'; + } + + function truncate(str, max) { + return str.length > max ? '…' + str.slice(-(max - 1)) : str; + } + + function update(msg) { + const { state, model, loading, metrics, request_count } = msg; + + applyState(state); + + stateLabel.textContent = state.replace('_', ' ').toUpperCase(); + svState.textContent = state.replace('_', ' '); + svModel.textContent = model ? truncate(model, 22) : '—'; + svReqs.textContent = request_count ?? 0; + + // Loading progress + if (loading) { + const pct = Math.round((loading.progress ?? 0) * 100); + progressFill.style.width = pct + '%'; + progressLabel.textContent = loading.current + ' ' + pct + '%'; + svLoadStage.textContent = loading.current; + } else { + progressFill.style.width = '0%'; + progressLabel.textContent = ''; + svLoadStage.textContent = '—'; + } + + // Metrics + if (metrics) { + svPrompt.textContent = fmt(metrics.prompt_speed, 't/s'); + svSpeed.textContent = fmt(metrics.gen_speed, 't/s'); + svTokens.textContent = metrics.n_decoded != null ? metrics.n_decoded + ' tok' : '—'; + } else { + svPrompt.textContent = svSpeed.textContent = svTokens.textContent = '—'; + } + + // Pulse dot on message + wsDot.classList.remove('pulse'); + void wsDot.offsetWidth; // force reflow to restart animation + wsDot.classList.add('pulse'); + } + + // ── WebSocket with auto-reconnect ───────────────────────────────────────── + let ws = null; + let retryDelay = 1000; + const MAX_DELAY = 30000; + + function connect() { + const url = 'ws://' + location.host + '/ws'; + ws = new WebSocket(url); + + ws.addEventListener('open', () => { + wsDot.className = 'connected'; + retryDelay = 1000; + }); + + ws.addEventListener('message', (event) => { + try { + update(JSON.parse(event.data)); + } catch (e) { + console.error('llamagochi: bad message', e); + } + }); + + ws.addEventListener('close', () => { + wsDot.className = ''; + applyState('offline'); + stateLabel.textContent = 'DISCONNECTED'; + setTimeout(connect, retryDelay); + retryDelay = Math.min(retryDelay * 2, MAX_DELAY); + }); + + ws.addEventListener('error', () => { + ws.close(); + }); + } + + connect(); +})();