fix: wss support, polling fallback, cancellation, offline labels, screen sizing

- frontend/app.js: use wss:// when page is served over HTTPS; add HTTP polling
  fallback (/state every 2s) when WebSocket is down, amber dot = polling mode;
  stop polling on WS reconnect; clearer state labels (LOG OFFLINE, LOAD ERROR,
  DISCONNECTED); fix replace('_',' ') to use regex for multi-underscore states
- frontend/style.css: add #ws-dot.polling (amber); min-height 280px on screen;
  shrink llama-wrap/SVG; tighten screen padding and gap
- log_parser.py: detect cancelled requests (stop: cancel task / Connection
  handling canceled) → idle; fix: switch to generating at progress=1.00
- server.py: add GET /state endpoint for polling fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-06-26 17:59:54 +03:00
parent 9dcb5b977a
commit 8712931c23
4 changed files with 59 additions and 14 deletions

View File

@@ -6,7 +6,7 @@
const wsDot = document.getElementById('ws-dot'); const wsDot = document.getElementById('ws-dot');
const statsPanel = document.getElementById('stats-panel'); const statsPanel = document.getElementById('stats-panel');
const resizeHandle = document.getElementById('resize-handle'); const resizeHandle = document.getElementById('resize-handle');
const stateLabel = document.getElementById('state-label'); const stateLabelEl = document.getElementById('state-label');
const progressFill = document.getElementById('progress-fill'); const progressFill = document.getElementById('progress-fill');
const progressLabel = document.getElementById('progress-label'); const progressLabel = document.getElementById('progress-label');
const svModel = document.getElementById('sv-model'); const svModel = document.getElementById('sv-model');
@@ -23,6 +23,15 @@
'sleeping','waiting','processing_prompt','generating','unloading','error' '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) { function applyState(stateName) {
ALL_STATES.forEach(s => body.classList.remove('state-' + s)); ALL_STATES.forEach(s => body.classList.remove('state-' + s));
body.classList.add('state-' + (ALL_STATES.includes(stateName) ? stateName : 'offline')); body.classList.add('state-' + (ALL_STATES.includes(stateName) ? stateName : 'offline'));
@@ -42,8 +51,8 @@
applyState(state); applyState(state);
stateLabel.textContent = state.replace('_', ' ').toUpperCase(); stateLabelEl.textContent = stateLabel(state);
svState.textContent = state.replace('_', ' '); svState.textContent = stateLabel(state).toLowerCase();
svModel.textContent = model || '—'; svModel.textContent = model || '—';
svReqs.textContent = request_count ?? 0; svReqs.textContent = request_count ?? 0;
@@ -111,16 +120,43 @@
document.addEventListener('touchmove', e => { if (dragging) { e.preventDefault(); onDrag(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); document.addEventListener('touchend', endDrag);
// ── 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 ───────────────────────────────────────── // ── WebSocket with auto-reconnect ─────────────────────────────────────────
let ws = null; let ws = null;
let retryDelay = 1000; let retryDelay = 1000;
const MAX_DELAY = 30000; const MAX_DELAY = 30000;
function connect() { function connect() {
const url = 'ws://' + location.host + '/ws'; const url = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws';
ws = new WebSocket(url); ws = new WebSocket(url);
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
stopPolling();
wsDot.className = 'connected'; wsDot.className = 'connected';
retryDelay = 1000; retryDelay = 1000;
}); });
@@ -134,9 +170,7 @@
}); });
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
wsDot.className = ''; startPolling();
applyState('offline');
stateLabel.textContent = 'DISCONNECTED';
setTimeout(connect, retryDelay); setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, MAX_DELAY); retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
}); });

View File

@@ -46,15 +46,15 @@ html, body {
#screen { #screen {
position: relative; position: relative;
width: 280px; width: 280px;
min-height: 380px; min-height: 280px;
background: #0d0d06; background: #0d0d06;
border: 3px solid var(--amber-dim); border: 3px solid var(--amber-dim);
border-radius: 12px; border-radius: 12px;
padding: 1.5rem 1rem 1rem; padding: 1.2rem 1rem 0.8rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.8rem; gap: 0.5rem;
/* CRT scanline overlay */ /* CRT scanline overlay */
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
0deg, 0deg,
@@ -78,6 +78,7 @@ html, body {
transition: background 0.3s; transition: background 0.3s;
} }
#ws-dot.connected { background: var(--green); } #ws-dot.connected { background: var(--green); }
#ws-dot.polling { background: var(--amber-dim); }
#ws-dot.pulse { animation: pulse-dot 0.3s ease-out; } #ws-dot.pulse { animation: pulse-dot 0.3s ease-out; }
@keyframes pulse-dot { @keyframes pulse-dot {
@@ -88,16 +89,16 @@ html, body {
/* ── Llama container ─────────────────────────────────────────────────────── */ /* ── Llama container ─────────────────────────────────────────────────────── */
#llama-wrap { #llama-wrap {
position: relative; position: relative;
width: 160px; width: 150px;
height: 200px; height: 175px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
#llama { #llama {
width: 140px; width: 135px;
height: 180px; height: 160px;
} }
/* SVG part colors */ /* SVG part colors */

View File

@@ -85,6 +85,8 @@ _RELEASE_RE = re.compile(r'slot\s+release:.*stop processing')
_UNLOAD_RE = re.compile(r'unload_lru:|unload: stopping model instance') _UNLOAD_RE = re.compile(r'unload_lru:|unload: stopping model instance')
_LOAD_ERROR_RE = re.compile(r'exiting due to model loading error') _LOAD_ERROR_RE = re.compile(r'exiting due to model loading error')
_EXIT_ERROR_RE = re.compile(r'operator\(\):.*exited with status 1') _EXIT_ERROR_RE = re.compile(r'operator\(\):.*exited with status 1')
_CLIENT_CANCEL_RE = re.compile(r'http client error: Connection handling canceled')
_CANCEL_TASK_RE = re.compile(r'stop: cancel task')
# ── State machine ───────────────────────────────────────────────────────────── # ── State machine ─────────────────────────────────────────────────────────────
@@ -125,6 +127,8 @@ class LogParser:
return self._set(state="unloading") return self._set(state="unloading")
if _EXIT_ERROR_RE.search(content): if _EXIT_ERROR_RE.search(content):
return self._set(state="error") return self._set(state="error")
if _CLIENT_CANCEL_RE.search(content):
return self._set(state="idle", loading=None, metrics=Metrics())
return None return None
# ── Child-process lines ────────────────────────────────────────────────── # ── Child-process lines ──────────────────────────────────────────────────
@@ -136,6 +140,8 @@ class LogParser:
return self._handle_cmd_state(m.group(1)) return self._handle_cmd_state(m.group(1))
if _LOAD_ERROR_RE.search(content): if _LOAD_ERROR_RE.search(content):
return self._set(state="error") return self._set(state="error")
if _CANCEL_TASK_RE.search(content):
return self._set(state="idle", loading=None, metrics=Metrics())
if _WARMUP_RE.search(content): if _WARMUP_RE.search(content):
return self._set(state="warming_up") return self._set(state="warming_up")
if _IDLE_RE.search(content): if _IDLE_RE.search(content):

View File

@@ -29,6 +29,10 @@ def create_app(log_path: str, reopen_log: bool = False) -> FastAPI:
async def index(): async def index():
return FileResponse(FRONTEND_DIR / "index.html") return FileResponse(FRONTEND_DIR / "index.html")
@app.get("/state")
async def get_state():
return last_state
@app.websocket("/ws") @app.websocket("/ws")
async def ws_endpoint(ws: WebSocket): async def ws_endpoint(ws: WebSocket):
await ws.accept() await ws.accept()