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>
This commit is contained in:
Vlad Doloman
2026-06-22 22:10:43 +03:00
parent 311819b51c
commit 9dcb5b977a
4 changed files with 49 additions and 21 deletions

View File

@@ -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) {

View File

@@ -64,18 +64,23 @@
<!-- Mouth: open (talking) -->
<ellipse class="part mouth-open" cx="50" cy="39" rx="4.5" ry="3"/>
<!-- Front legs -->
<rect class="part leg leg-fl" x="32" y="100" width="10" height="30" rx="4"/>
<rect class="part leg leg-fr" x="46" y="100" width="10" height="30" rx="4"/>
<!-- Back legs -->
<rect class="part leg leg-bl" x="57" y="100" width="10" height="30" rx="4"/>
<rect class="part leg leg-br" x="71" y="100" width="10" height="30" rx="4"/>
<!-- Hooves -->
<rect class="part hoof" x="32" y="126" width="10" height="4" rx="2"/>
<rect class="part hoof" x="46" y="126" width="10" height="4" rx="2"/>
<rect class="part hoof" x="57" y="126" width="10" height="4" rx="2"/>
<rect class="part hoof" x="71" y="126" width="10" height="4" rx="2"/>
<!-- Legs + hooves (grouped so animation moves both together) -->
<g class="leg-fl">
<rect class="part leg" x="29" y="100" width="10" height="30" rx="4"/>
<rect class="part hoof" x="29" y="126" width="10" height="4" rx="2"/>
</g>
<g class="leg-fr">
<rect class="part leg" x="40" y="100" width="10" height="30" rx="4"/>
<rect class="part hoof" x="40" y="126" width="10" height="4" rx="2"/>
</g>
<g class="leg-bl">
<rect class="part leg" x="51" y="100" width="10" height="30" rx="4"/>
<rect class="part hoof" x="51" y="126" width="10" height="4" rx="2"/>
</g>
<g class="leg-br">
<rect class="part leg" x="62" y="100" width="10" height="30" rx="4"/>
<rect class="part hoof" x="62" y="126" width="10" height="4" rx="2"/>
</g>
<!-- Book (reading) -->
<g class="part book">

View File

@@ -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 {

View File

@@ -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(