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(