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:
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user