diff --git a/frontend/app.js b/frontend/app.js index 66cccfb..8d9b86e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -20,7 +20,7 @@ // ── State class management ──────────────────────────────────────────────── const ALL_STATES = [ 'offline','starting','loading','warming_up','idle', - 'sleeping','waiting','processing_prompt','generating','unloading' + 'sleeping','waiting','processing_prompt','generating','unloading','error' ]; function applyState(stateName) { diff --git a/frontend/index.html b/frontend/index.html index 33743ae..e8de547 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -64,18 +64,23 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/frontend/style.css b/frontend/style.css index ff4bf2a..bd9e89a 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -145,6 +145,14 @@ body.state-processing_prompt #progress-wrap { display: flex; } /* offline */ body.state-offline #llama { opacity: 0.15; } +/* error: llama flashes red */ +body.state-error { --amber: var(--red); --amber-dim: #cc3333; --amber-dk: #882222; } +body.state-error #llama { animation: error-flash 1s ease-in-out infinite; } +@keyframes error-flash { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + /* ── ZZZ ─────────────────────────────────────────────────────────────────── */ #zzz-wrap { position: absolute; @@ -341,6 +349,11 @@ body.state-unloading #row-speed, body.state-unloading #row-tokens, body.state-unloading #row-load-stage { display: none; } +body.state-error #row-prompt, +body.state-error #row-speed, +body.state-error #row-tokens, +body.state-error #row-load-stage { display: none; } + body.state-processing_prompt #row-speed, body.state-processing_prompt #row-load-stage { display: none; } @@ -390,12 +403,12 @@ body.state-waiting .right-ear { body.state-loading .leg-fl, body.state-loading .leg-bl { animation: walk-a 0.7s ease-in-out infinite; - transform-origin: 37px 100px; + transform-origin: 34px 100px; } body.state-loading .leg-fr, body.state-loading .leg-br { animation: walk-b 0.7s ease-in-out infinite; - transform-origin: 51px 100px; + transform-origin: 45px 100px; } @keyframes walk-a { diff --git a/log_parser.py b/log_parser.py index 843fe31..ed757e6 100644 --- a/log_parser.py +++ b/log_parser.py @@ -83,6 +83,8 @@ _PROMPT_SUMMARY_RE = re.compile( ) _RELEASE_RE = re.compile(r'slot\s+release:.*stop processing') _UNLOAD_RE = re.compile(r'unload_lru:|unload: stopping model instance') +_LOAD_ERROR_RE = re.compile(r'exiting due to model loading error') +_EXIT_ERROR_RE = re.compile(r'operator\(\):.*exited with status 1') # ── State machine ───────────────────────────────────────────────────────────── @@ -121,6 +123,8 @@ class LogParser: return self._set(state="waiting") if _UNLOAD_RE.search(content): return self._set(state="unloading") + if _EXIT_ERROR_RE.search(content): + return self._set(state="error") return None # ── Child-process lines ────────────────────────────────────────────────── @@ -130,6 +134,8 @@ class LogParser: if m := _CMD_STATE_RE.match(content): return self._handle_cmd_state(m.group(1)) + if _LOAD_ERROR_RE.search(content): + return self._set(state="error") if _WARMUP_RE.search(content): return self._set(state="warming_up") if _IDLE_RE.search(content): @@ -137,14 +143,14 @@ class LogParser: if _LAUNCH_RE.search(content): return self._set(state="processing_prompt") if m := _PROMPT_TIMING_RE.search(content): + progress = float(m.group(2)) + done = progress >= 1.0 metrics = Metrics( prompt_speed=float(m.group(3)), prompt_tokens=int(m.group(1)), - prompt_progress=float(m.group(2)), - gen_speed=self._state.metrics.gen_speed, - n_decoded=self._state.metrics.n_decoded, + prompt_progress=None if done else progress, ) - return self._set(state="processing_prompt", metrics=metrics) + return self._set(state="generating" if done else "processing_prompt", metrics=metrics) if m := _GEN_TIMING_RE.search(content): metrics = Metrics( prompt_speed=self._state.metrics.prompt_speed, @@ -154,14 +160,18 @@ class LogParser: ) return self._set(state="generating", metrics=metrics) if m := _PROMPT_SUMMARY_RE.search(content): - # End-of-slot summary: update prompt speed without changing state. - # For short prompts this fires after generating starts (no live prompt line). metrics = Metrics( prompt_speed=float(m.group(2)), prompt_tokens=int(m.group(1)), gen_speed=self._state.metrics.gen_speed, n_decoded=self._state.metrics.n_decoded, ) + if self._state.state == "processing_prompt": + # Summary fires right after prompt eval finishes, before first token. + # Advance to generating now rather than waiting for the first tg line. + # For short prompts the summary arrives after generating has already + # started, so the guard prevents stepping back. + return self._set(state="generating", metrics=metrics) return self._set(metrics=metrics) if _RELEASE_RE.search(content): return self._set(