(function () { 'use strict'; // ── DOM refs ────────────────────────────────────────────────────────────── const body = document.body; const wsDot = document.getElementById('ws-dot'); const statsPanel = document.getElementById('stats-panel'); const resizeHandle = document.getElementById('resize-handle'); const stateLabelEl = 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 svSleepTimer = document.getElementById('sv-sleep-timer'); const svReqs = document.getElementById('sv-reqs'); // ── State class management ──────────────────────────────────────────────── const ALL_STATES = [ 'offline','starting','loading','warming_up','idle', 'sleeping','waiting','processing_prompt','generating','unloading','error' ]; const STATE_LABELS = { offline: 'LOG OFFLINE', // llamagochi can't read the log file error: 'LOAD ERROR', }; function stateLabel(stateName) { return STATE_LABELS[stateName] ?? stateName.replace(/_/g, ' ').toUpperCase(); } 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, sleeping_since } = msg; applyState(state); stateLabelEl.textContent = stateLabel(state); svState.textContent = stateLabel(state).toLowerCase(); svModel.textContent = model || '—'; svReqs.textContent = request_count ?? 0; // Progress bar: model loading or prompt processing if (loading) { const stageName = (loading.current ?? '').replace(/_/g, ' '); const stageIdx = (loading.stages ?? []).indexOf(loading.current); const totalStages = (loading.stages ?? []).length; const stagePct = Math.round((loading.progress ?? 0) * 100); const overallPct = totalStages > 0 ? Math.round(((stageIdx + (loading.progress ?? 0)) / totalStages) * 100) : stagePct; progressFill.style.width = overallPct + '%'; progressLabel.textContent = stageName + (totalStages > 1 ? ' ' + (stageIdx + 1) + '/' + totalStages : '') + ' ' + stagePct + '%'; svLoadStage.textContent = totalStages > 1 ? stageName + ' (' + (stageIdx + 1) + '/' + totalStages + ')' : stageName; } else if (state === 'processing_prompt' && metrics && metrics.prompt_progress != null) { const pct = Math.round(metrics.prompt_progress * 100); progressFill.style.width = pct + '%'; progressLabel.textContent = 'PROMPT ' + pct + '%'; svLoadStage.textContent = '—'; } 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 = '—'; } // Sleep timer if (state === 'sleeping' && sleeping_since) { startSleepTimer(sleeping_since); } else { stopSleepTimer(); } // Pulse dot on message wsDot.classList.remove('pulse'); void wsDot.offsetWidth; // force reflow to restart animation wsDot.classList.add('pulse'); } // ── Stats panel resize ─────────────────────────────────────────────────── const SCREEN_W = 280; let dragStartX = 0, dragStartW = 0, dragging = false; function startDrag(x) { dragging = true; dragStartX = x; dragStartW = statsPanel.offsetWidth; resizeHandle.classList.add('dragging'); body.style.cursor = 'ew-resize'; body.style.userSelect = 'none'; } function onDrag(x) { if (!dragging) return; const w = Math.min(Math.max(dragStartW + (x - dragStartX), SCREEN_W), SCREEN_W * 3); statsPanel.style.flex = '0 0 ' + w + 'px'; } function endDrag() { if (!dragging) return; dragging = false; resizeHandle.classList.remove('dragging'); body.style.cursor = body.style.userSelect = ''; } resizeHandle.addEventListener('mousedown', e => { e.preventDefault(); startDrag(e.clientX); }); document.addEventListener('mousemove', e => onDrag(e.clientX)); document.addEventListener('mouseup', endDrag); resizeHandle.addEventListener('touchstart', e => { e.preventDefault(); startDrag(e.touches[0].clientX); }, { passive: false }); document.addEventListener('touchmove', e => { if (dragging) { e.preventDefault(); onDrag(e.touches[0].clientX); } }, { passive: false }); document.addEventListener('touchend', endDrag); // ── Sleep timer ─────────────────────────────────────────────────────────── let sleepingSince = null; let sleepInterval = null; function formatDuration(sec) { const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (h > 0) return h + 'h ' + m + 'm'; if (m > 0) return m + 'm ' + s + 's'; return s + 's'; } function tickSleepTimer() { if (!sleepingSince) return; const elapsed = Math.floor((Date.now() - new Date(sleepingSince).getTime()) / 1000); svSleepTimer.textContent = formatDuration(elapsed); } function startSleepTimer(since) { if (sleepingSince === since) return; sleepingSince = since; clearInterval(sleepInterval); tickSleepTimer(); sleepInterval = setInterval(tickSleepTimer, 1000); } function stopSleepTimer() { sleepingSince = null; clearInterval(sleepInterval); sleepInterval = null; svSleepTimer.textContent = '—'; } // ── HTTP polling fallback ───────────────────────────────────────────────── let pollTimer = null; const POLL_MS = 2000; async function pollOnce() { try { const r = await fetch('/state'); if (!r.ok) throw new Error(r.status); update(await r.json()); wsDot.className = 'polling'; } catch { wsDot.className = ''; // red: llamagochi itself unreachable } } function startPolling() { if (pollTimer) return; pollOnce(); pollTimer = setInterval(pollOnce, POLL_MS); } function stopPolling() { clearInterval(pollTimer); pollTimer = null; } // ── WebSocket with auto-reconnect ───────────────────────────────────────── let ws = null; let retryDelay = 1000; const MAX_DELAY = 30000; function connect() { const url = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws'; ws = new WebSocket(url); ws.addEventListener('open', () => { stopPolling(); 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', () => { startPolling(); setTimeout(connect, retryDelay); retryDelay = Math.min(retryDelay * 2, MAX_DELAY); }); ws.addEventListener('error', () => { ws.close(); }); } connect(); })();