Files
Llamagochi/frontend/app.js
Vlad Doloman 9dcb5b977a fix: error state, leg grouping, prompt→generating transition
- log_parser: detect model load failure (child + router patterns) → state=error;
  switch to generating at progress=1.00 and at prompt eval summary line
- frontend: add state-error (llama flashes red, CSS variable override);
  group each leg+hoof in <g> so walk animation moves hooves with legs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:10:43 +03:00

151 lines
5.9 KiB
JavaScript

(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 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','error'
];
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 || '—';
svReqs.textContent = request_count ?? 0;
// Progress bar: model loading or prompt processing
if (loading) {
const pct = Math.round((loading.progress ?? 0) * 100);
progressFill.style.width = pct + '%';
progressLabel.textContent = loading.current + ' ' + pct + '%';
svLoadStage.textContent = loading.current;
} 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 = '—';
}
// 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);
// ── 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();
})();