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 ──────────────────────────────────────────────── // ── State class management ────────────────────────────────────────────────
const ALL_STATES = [ const ALL_STATES = [
'offline','starting','loading','warming_up','idle', 'offline','starting','loading','warming_up','idle',
'sleeping','waiting','processing_prompt','generating','unloading' 'sleeping','waiting','processing_prompt','generating','unloading','error'
]; ];
function applyState(stateName) { function applyState(stateName) {

View File

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

View File

@@ -145,6 +145,14 @@ body.state-processing_prompt #progress-wrap { display: flex; }
/* offline */ /* offline */
body.state-offline #llama { opacity: 0.15; } 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 ─────────────────────────────────────────────────────────────────── */
#zzz-wrap { #zzz-wrap {
position: absolute; position: absolute;
@@ -341,6 +349,11 @@ body.state-unloading #row-speed,
body.state-unloading #row-tokens, body.state-unloading #row-tokens,
body.state-unloading #row-load-stage { display: none; } 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-speed,
body.state-processing_prompt #row-load-stage { display: none; } 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-fl,
body.state-loading .leg-bl { body.state-loading .leg-bl {
animation: walk-a 0.7s ease-in-out infinite; 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-fr,
body.state-loading .leg-br { body.state-loading .leg-br {
animation: walk-b 0.7s ease-in-out infinite; animation: walk-b 0.7s ease-in-out infinite;
transform-origin: 51px 100px; transform-origin: 45px 100px;
} }
@keyframes walk-a { @keyframes walk-a {

View File

@@ -83,6 +83,8 @@ _PROMPT_SUMMARY_RE = re.compile(
) )
_RELEASE_RE = re.compile(r'slot\s+release:.*stop processing') _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')
_EXIT_ERROR_RE = re.compile(r'operator\(\):.*exited with status 1')
# ── State machine ───────────────────────────────────────────────────────────── # ── State machine ─────────────────────────────────────────────────────────────
@@ -121,6 +123,8 @@ class LogParser:
return self._set(state="waiting") return self._set(state="waiting")
if _UNLOAD_RE.search(content): if _UNLOAD_RE.search(content):
return self._set(state="unloading") return self._set(state="unloading")
if _EXIT_ERROR_RE.search(content):
return self._set(state="error")
return None return None
# ── Child-process lines ────────────────────────────────────────────────── # ── Child-process lines ──────────────────────────────────────────────────
@@ -130,6 +134,8 @@ class LogParser:
if m := _CMD_STATE_RE.match(content): if m := _CMD_STATE_RE.match(content):
return self._handle_cmd_state(m.group(1)) return self._handle_cmd_state(m.group(1))
if _LOAD_ERROR_RE.search(content):
return self._set(state="error")
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):
@@ -137,14 +143,14 @@ class LogParser:
if _LAUNCH_RE.search(content): if _LAUNCH_RE.search(content):
return self._set(state="processing_prompt") return self._set(state="processing_prompt")
if m := _PROMPT_TIMING_RE.search(content): if m := _PROMPT_TIMING_RE.search(content):
progress = float(m.group(2))
done = progress >= 1.0
metrics = Metrics( metrics = Metrics(
prompt_speed=float(m.group(3)), prompt_speed=float(m.group(3)),
prompt_tokens=int(m.group(1)), prompt_tokens=int(m.group(1)),
prompt_progress=float(m.group(2)), prompt_progress=None if done else progress,
gen_speed=self._state.metrics.gen_speed,
n_decoded=self._state.metrics.n_decoded,
) )
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): if m := _GEN_TIMING_RE.search(content):
metrics = Metrics( metrics = Metrics(
prompt_speed=self._state.metrics.prompt_speed, prompt_speed=self._state.metrics.prompt_speed,
@@ -154,14 +160,18 @@ class LogParser:
) )
return self._set(state="generating", metrics=metrics) return self._set(state="generating", metrics=metrics)
if m := _PROMPT_SUMMARY_RE.search(content): 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( metrics = Metrics(
prompt_speed=float(m.group(2)), prompt_speed=float(m.group(2)),
prompt_tokens=int(m.group(1)), prompt_tokens=int(m.group(1)),
gen_speed=self._state.metrics.gen_speed, gen_speed=self._state.metrics.gen_speed,
n_decoded=self._state.metrics.n_decoded, 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) return self._set(metrics=metrics)
if _RELEASE_RE.search(content): if _RELEASE_RE.search(content):
return self._set( return self._set(