Compare commits

...

15 Commits

Author SHA1 Message Date
Vlad Doloman
71b3c945ee feat: sleep duration timer in stats panel
Track sleeping_since (ISO timestamp) in ServerState; auto-cleared by _set()
whenever state leaves sleeping. Frontend runs a 1s interval showing elapsed
time as "Xs", "Xm Ys", or "Xh Ym" in a new ASLEEP stat row (visible only
during state-sleeping). Timer survives page reload via server-side timestamp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 13:49:50 +03:00
Vlad Doloman
a5e59df624 feat: show loading stage name, count, and overall progress
Progress bar now fills to overall completion across all stages.
Label shows e.g. "text model  1/3  23%" and stats panel shows
"text model (1/3)". Label bumped to 8px full amber for readability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:29:45 +03:00
Vlad Doloman
7bda83998e fix: handle bare {"stage":"name"} loading payload; harden watcher against parser errors
The stage-transition payload {"state":"loading","payload":{"stage":"mmproj_model"}}
lacks the stages/current/value keys — accessing them raised KeyError which crashed
the LogWatcher task, freezing the UI at the last good state indefinitely.

- log_parser: skip loading payloads missing stages/current/value (next message
  has the full payload within milliseconds)
- log_watcher: wrap on_line calls in try/except so any future parser error
  keeps the tail loop alive instead of killing the task

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:27:52 +03:00
Vlad Doloman
e2996ff0b3 feat: add SVG favicon (llama head, amber on dark)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:21:03 +03:00
Vlad Doloman
ce9b2d73f7 docs: add README with setup, config, states, and reverse proxy guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:17:54 +03:00
Vlad Doloman
8712931c23 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>
2026-06-26 17:59:54 +03:00
Vlad Doloman
9dcb5b977a 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>
2026-06-22 22:10:43 +03:00
Vlad Doloman
311819b51c fix: sshfs polling, WebSocket broadcast, prompt progress bar, resizable stats panel
- log_watcher: add --reopen-log mode (close/reopen each poll) for sshfs/network
  filesystems; fix nonlocal clients bug causing UnboundLocalError on first broadcast;
  fix list(clients) copy to prevent RuntimeError on concurrent set mutation
- log_parser: capture progress=X.XX from prompt timing lines into Metrics.prompt_progress
- frontend: show progress bar during processing_prompt state with PROMPT N% label;
  widen stats panel to 300px; remove model name truncation; add drag handle on
  right border of stats panel to resize between 280px–840px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:02:34 +03:00
Vlad Doloman
fddbd8f4d4 fix: seed initial state from last 8KB of log on startup 2026-06-22 20:11:00 +03:00
Vlad Doloman
c3b06d4e99 feat: WebSocket client with state animation mapping and auto-reconnect 2026-06-22 19:05:27 +03:00
Vlad Doloman
7e82eaf3f9 feat: LCD aesthetic CSS with responsive layout and per-state animations 2026-06-22 19:03:33 +03:00
Vlad Doloman
5541822936 feat: frontend HTML structure with SVG llama skeleton 2026-06-22 19:01:15 +03:00
Vlad Doloman
2e757fa645 feat: FastAPI server with WebSocket broadcast and static serving
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:58:49 +03:00
Vlad Doloman
3367eeb80b feat: asyncio log file watcher with offline detection 2026-06-22 18:57:17 +03:00
Vlad Doloman
1411ba241f feat: LogParser state machine with full state transitions 2026-06-22 18:46:12 +03:00
11 changed files with 1667 additions and 4 deletions

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# Llamagochi
A browser-based virtual pet llama that mirrors a running `llama-server` instance by tailing its log file. The llama animates differently depending on what the server is doing — loading a model, processing a prompt, generating tokens, sleeping, or erroring out.
```
IDLE PROCESSING PROMPT GENERATING SLEEPING
ear twitches eyes scan left/right mouth talks zzz floats up
progress bar shown tail wags
```
## Requirements
- Python 3.10+
- `llama-server` running in **router mode** (the multi-model router that logs `cmd_child_to_router:state:{...}` JSON lines)
- The log file must be accessible from the machine running Llamagochi (local path or a mount)
## Installation
```bash
git clone <repo>
cd llamagochi
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
## Running
```bash
python server.py --log /path/to/llama-server.log
```
The web UI is then available at `http://localhost:8080`.
### Options
| Flag | Default | Description |
|---|---|---|
| `--log PATH` | — | Path to the llama-server log file (required unless set in `config.json` or `$LLAMAGOCHI_LOG`) |
| `--port PORT` | `8080` | HTTP port to listen on |
| `--reopen-log` | off | Close and reopen the log file on every poll cycle. Use this when the log is on a network filesystem (sshfs, NFS) that caches file content at open time |
### Configuration file
Instead of passing `--log` every time, create `config.json` in the project directory:
```json
{
"log": "/path/to/llama-server.log",
"reopen_log": true
}
```
`reopen_log` in the config is equivalent to `--reopen-log` on the command line. CLI flags take precedence.
### Environment variable
```bash
export LLAMAGOCHI_LOG=/path/to/llama-server.log
python server.py
```
## Reverse proxy (HTTPS)
Llamagochi uses WebSocket (`/ws`) and HTTP polling (`/state`). Both must be proxied. Example nginx snippet:
```nginx
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
```
The frontend automatically uses `wss://` when the page is served over HTTPS.
## States
| State | What it means |
|---|---|
| `idle` | Server is up, no active request |
| `waiting` | Router received a request, slot not yet assigned |
| `loading` | Model is loading into VRAM (progress bar shown) |
| `warming_up` | Model weights loaded, running warmup pass |
| `processing_prompt` | Evaluating the prompt (progress bar 0100%) |
| `generating` | Generating tokens |
| `sleeping` | Server idle-timeout reached, model evicted |
| `unloading` | LRU eviction in progress |
| `error` | Model failed to load |
| `offline` / `LOG OFFLINE` | Llamagochi cannot read the log file |
## Connection status dot (top-right of screen)
| Colour | Meaning |
|---|---|
| Green | WebSocket live — real-time updates |
| Amber | WebSocket down, falling back to HTTP polling every 2 s |
| Red | Both WebSocket and HTTP polling are unreachable |
When the WebSocket reconnects, polling stops automatically.
## Log file format
Llamagochi expects the log produced by `llama-server` router mode, optionally prefixed with timestamps by the `ts` utility:
```
[2026-06-22 20:09:31] [51201] 3.13.636.722 I srv update_slots: all slots are idle
[2026-06-22 20:09:31] 339.42.030.150 I srv proxy_reques: proxying request to model ...
```
Lines prefixed with `[PORT]` are child-process lines; unprefixed lines are router-level. Both are understood.
## Running tests
```bash
pytest
```
32 tests covering the log parser state machine and log watcher.

236
frontend/app.js Normal file
View File

@@ -0,0 +1,236 @@
(function () {
'use strict';
// ── DOM refs ──────────────────────────────────────────────────────────────
const body = document.body;
const wsDot = document.getElementById('ws-dot');
const statsPanel = document.getElementById('stats-panel');
const resizeHandle = document.getElementById('resize-handle');
const stateLabelEl = document.getElementById('state-label');
const progressFill = document.getElementById('progress-fill');
const progressLabel = document.getElementById('progress-label');
const svModel = document.getElementById('sv-model');
const svState = document.getElementById('sv-state');
const svPrompt = document.getElementById('sv-prompt');
const svSpeed = document.getElementById('sv-speed');
const svTokens = document.getElementById('sv-tokens');
const svLoadStage = document.getElementById('sv-load-stage');
const svSleepTimer = document.getElementById('sv-sleep-timer');
const svReqs = document.getElementById('sv-reqs');
// ── State class management ────────────────────────────────────────────────
const ALL_STATES = [
'offline','starting','loading','warming_up','idle',
'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) {
ALL_STATES.forEach(s => body.classList.remove('state-' + s));
body.classList.add('state-' + (ALL_STATES.includes(stateName) ? stateName : 'offline'));
}
// ── DOM update ────────────────────────────────────────────────────────────
function fmt(val, unit) {
return val != null ? val.toFixed(1) + ' ' + unit : '—';
}
function truncate(str, max) {
return str.length > max ? '…' + str.slice(-(max - 1)) : str;
}
function update(msg) {
const { state, model, loading, metrics, request_count, sleeping_since } = msg;
applyState(state);
stateLabelEl.textContent = stateLabel(state);
svState.textContent = stateLabel(state).toLowerCase();
svModel.textContent = model || '—';
svReqs.textContent = request_count ?? 0;
// Progress bar: model loading or prompt processing
if (loading) {
const stageName = (loading.current ?? '').replace(/_/g, ' ');
const stageIdx = (loading.stages ?? []).indexOf(loading.current);
const totalStages = (loading.stages ?? []).length;
const stagePct = Math.round((loading.progress ?? 0) * 100);
const overallPct = totalStages > 0
? Math.round(((stageIdx + (loading.progress ?? 0)) / totalStages) * 100)
: stagePct;
progressFill.style.width = overallPct + '%';
progressLabel.textContent = stageName
+ (totalStages > 1 ? ' ' + (stageIdx + 1) + '/' + totalStages : '')
+ ' ' + stagePct + '%';
svLoadStage.textContent = totalStages > 1
? stageName + ' (' + (stageIdx + 1) + '/' + totalStages + ')'
: stageName;
} else if (state === 'processing_prompt' && metrics && metrics.prompt_progress != null) {
const pct = Math.round(metrics.prompt_progress * 100);
progressFill.style.width = pct + '%';
progressLabel.textContent = 'PROMPT ' + pct + '%';
svLoadStage.textContent = '—';
} else {
progressFill.style.width = '0%';
progressLabel.textContent = '';
svLoadStage.textContent = '—';
}
// Metrics
if (metrics) {
svPrompt.textContent = fmt(metrics.prompt_speed, 't/s');
svSpeed.textContent = fmt(metrics.gen_speed, 't/s');
svTokens.textContent = metrics.n_decoded != null ? metrics.n_decoded + ' tok' : '—';
} else {
svPrompt.textContent = svSpeed.textContent = svTokens.textContent = '—';
}
// Sleep timer
if (state === 'sleeping' && sleeping_since) {
startSleepTimer(sleeping_since);
} else {
stopSleepTimer();
}
// Pulse dot on message
wsDot.classList.remove('pulse');
void wsDot.offsetWidth; // force reflow to restart animation
wsDot.classList.add('pulse');
}
// ── Stats panel resize ───────────────────────────────────────────────────
const SCREEN_W = 280;
let dragStartX = 0, dragStartW = 0, dragging = false;
function startDrag(x) {
dragging = true;
dragStartX = x;
dragStartW = statsPanel.offsetWidth;
resizeHandle.classList.add('dragging');
body.style.cursor = 'ew-resize';
body.style.userSelect = 'none';
}
function onDrag(x) {
if (!dragging) return;
const w = Math.min(Math.max(dragStartW + (x - dragStartX), SCREEN_W), SCREEN_W * 3);
statsPanel.style.flex = '0 0 ' + w + 'px';
}
function endDrag() {
if (!dragging) return;
dragging = false;
resizeHandle.classList.remove('dragging');
body.style.cursor = body.style.userSelect = '';
}
resizeHandle.addEventListener('mousedown', e => { e.preventDefault(); startDrag(e.clientX); });
document.addEventListener('mousemove', e => onDrag(e.clientX));
document.addEventListener('mouseup', endDrag);
resizeHandle.addEventListener('touchstart', e => { e.preventDefault(); startDrag(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);
// ── Sleep timer ───────────────────────────────────────────────────────────
let sleepingSince = null;
let sleepInterval = null;
function formatDuration(sec) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
function tickSleepTimer() {
if (!sleepingSince) return;
const elapsed = Math.floor((Date.now() - new Date(sleepingSince).getTime()) / 1000);
svSleepTimer.textContent = formatDuration(elapsed);
}
function startSleepTimer(since) {
if (sleepingSince === since) return;
sleepingSince = since;
clearInterval(sleepInterval);
tickSleepTimer();
sleepInterval = setInterval(tickSleepTimer, 1000);
}
function stopSleepTimer() {
sleepingSince = null;
clearInterval(sleepInterval);
sleepInterval = null;
svSleepTimer.textContent = '—';
}
// ── 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 ─────────────────────────────────────────
let ws = null;
let retryDelay = 1000;
const MAX_DELAY = 30000;
function connect() {
const url = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws';
ws = new WebSocket(url);
ws.addEventListener('open', () => {
stopPolling();
wsDot.className = 'connected';
retryDelay = 1000;
});
ws.addEventListener('message', (event) => {
try {
update(JSON.parse(event.data));
} catch (e) {
console.error('llamagochi: bad message', e);
}
});
ws.addEventListener('close', () => {
startPolling();
setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
});
ws.addEventListener('error', () => {
ws.close();
});
}
connect();
})();

19
frontend/favicon.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="22 2 56 48">
<rect x="22" y="2" width="56" height="48" rx="10" fill="#0a0a04"/>
<!-- Ears -->
<ellipse cx="38" cy="15" rx="5" ry="9" transform="rotate(-15 38 15)" fill="#ffb000"/>
<ellipse cx="62" cy="15" rx="5" ry="9" transform="rotate(15 62 15)" fill="#ffb000"/>
<!-- Head -->
<ellipse cx="50" cy="27" rx="16" ry="14" fill="#ffb000"/>
<!-- Nose -->
<ellipse cx="50" cy="34" rx="5.5" ry="3.5" fill="#ffb000"/>
<!-- Nostrils -->
<circle cx="47.5" cy="34" r="1.2" fill="#0a0a04"/>
<circle cx="52.5" cy="34" r="1.2" fill="#0a0a04"/>
<!-- Eyes -->
<circle cx="43" cy="25" r="2.8" fill="#0a0a04"/>
<circle cx="57" cy="25" r="2.8" fill="#0a0a04"/>
<!-- Eye shines -->
<circle cx="44.2" cy="23.8" r="0.9" fill="#ffb000"/>
<circle cx="58.2" cy="23.8" r="0.9" fill="#ffb000"/>
</svg>

After

Width:  |  Height:  |  Size: 859 B

164
frontend/index.html Normal file
View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Llamagochi</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="state-offline">
<div id="app">
<!-- ── Screen panel ───────────────────────────────────────────── -->
<div id="screen-panel">
<div id="screen">
<div id="ws-dot" title="WebSocket connection"></div>
<div id="llama-wrap">
<svg id="llama" viewBox="0 0 100 150" xmlns="http://www.w3.org/2000/svg">
<!-- Tail -->
<path class="part tail" d="M 72 82 Q 88 68 80 88" stroke-width="5"
fill="none" stroke-linecap="round"/>
<!-- Body -->
<rect class="part body" x="28" y="62" width="46" height="42" rx="13"/>
<!-- Neck -->
<rect class="part neck" x="42" y="33" width="16" height="35" rx="7"/>
<!-- Head -->
<ellipse class="part head" cx="50" cy="27" rx="16" ry="14"/>
<!-- Ears -->
<ellipse class="part ear left-ear" cx="38" cy="15" rx="5" ry="9"
transform="rotate(-15 38 15)"/>
<ellipse class="part ear right-ear" cx="62" cy="15" rx="5" ry="9"
transform="rotate(15 62 15)"/>
<!-- Eyes: open (default) -->
<circle class="part eye left-eye" cx="43" cy="25" r="2.8"/>
<circle class="part eye right-eye" cx="57" cy="25" r="2.8"/>
<circle class="shine" cx="44.2" cy="23.8" r="0.9"/>
<circle class="shine" cx="58.2" cy="23.8" r="0.9"/>
<!-- Eyes: closed (sleeping) -->
<path class="part sleep-eye left-sleep-eye"
d="M 40 25 Q 43 23 46 25" stroke-width="2" fill="none" stroke-linecap="round"/>
<path class="part sleep-eye right-sleep-eye"
d="M 54 25 Q 57 23 60 25" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Nose -->
<ellipse class="part nose" cx="50" cy="34" rx="5.5" ry="3.5"/>
<circle cx="47.5" cy="34" r="1.2" class="nostril"/>
<circle cx="52.5" cy="34" r="1.2" class="nostril"/>
<!-- Mouth: closed smile -->
<path class="part mouth-closed"
d="M 47 38 Q 50 41 53 38" stroke-width="1.8" fill="none" stroke-linecap="round"/>
<!-- Mouth: open (talking) -->
<ellipse class="part mouth-open" cx="50" cy="39" rx="4.5" ry="3"/>
<!-- 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">
<rect x="10" y="68" width="22" height="16" rx="2"/>
<line x1="21" y1="68" x2="21" y2="84" stroke-width="1"/>
<line x1="12" y1="73" x2="20" y2="73" stroke-width="1"/>
<line x1="12" y1="77" x2="20" y2="77" stroke-width="1"/>
<line x1="22" y1="73" x2="30" y2="73" stroke-width="1"/>
<line x1="22" y1="77" x2="30" y2="77" stroke-width="1"/>
</g>
</svg>
<!-- ZZZ bubble (sleeping) -->
<div id="zzz-wrap" aria-hidden="true">
<span class="z z1">Z</span>
<span class="z z2">Z</span>
<span class="z z3">Z</span>
</div>
<!-- Speech bubble (generating) -->
<div id="speech-bubble" aria-hidden="true">
<span class="dot d1">.</span><span class="dot d2">.</span><span class="dot d3">.</span>
</div>
</div><!-- /llama-wrap -->
<div id="state-label">OFFLINE</div>
<!-- Loading progress bar (loading state only) -->
<div id="progress-wrap">
<div id="progress-bar"><div id="progress-fill"></div></div>
<div id="progress-label"></div>
</div>
</div><!-- /screen -->
</div><!-- /screen-panel -->
<!-- ── Stats panel ────────────────────────────────────────────── -->
<div id="stats-panel">
<div id="resize-handle" title="Drag to resize"></div>
<div class="stat-row" id="row-model">
<span class="sk">MODEL</span>
<span class="sv" id="sv-model"></span>
</div>
<div class="stat-row" id="row-state">
<span class="sk">STATE</span>
<span class="sv" id="sv-state">offline</span>
</div>
<div class="stat-row" id="row-prompt">
<span class="sk">PROMPT</span>
<span class="sv" id="sv-prompt"></span>
</div>
<div class="stat-row" id="row-speed">
<span class="sk">SPEED</span>
<span class="sv" id="sv-speed"></span>
</div>
<div class="stat-row" id="row-tokens">
<span class="sk">TOKENS</span>
<span class="sv" id="sv-tokens"></span>
</div>
<div class="stat-row" id="row-load-stage">
<span class="sk">STAGE</span>
<span class="sv" id="sv-load-stage"></span>
</div>
<div class="stat-row" id="row-sleep-timer">
<span class="sk">ASLEEP</span>
<span class="sv" id="sv-sleep-timer"></span>
</div>
<div class="stat-row" id="row-reqs">
<span class="sk">REQS</span>
<span class="sv" id="sv-reqs">0</span>
</div>
</div><!-- /stats-panel -->
</div><!-- /app -->
<script src="/static/app.js"></script>
</body>
</html>

477
frontend/style.css Normal file
View File

@@ -0,0 +1,477 @@
/* ── Reset & base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a04;
--amber: #ffb000;
--amber-dim: #a07000;
--amber-dk: #6a4a00;
--green: #00ff88;
--red: #ff4444;
--font: 'Press Start 2P', monospace;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--amber);
font-family: var(--font);
font-size: 10px;
}
/* ── Two-panel layout ────────────────────────────────────────────────────── */
#app {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
gap: 2rem;
padding: 2rem;
}
/* Portrait / narrow: stack vertically */
@media (orientation: portrait) {
#app {
flex-direction: column;
align-items: center;
}
}
/* ── Screen panel ────────────────────────────────────────────────────────── */
#screen-panel {
flex: 0 0 auto;
}
#screen {
position: relative;
width: 280px;
min-height: 280px;
background: #0d0d06;
border: 3px solid var(--amber-dim);
border-radius: 12px;
padding: 1.2rem 1rem 0.8rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
/* CRT scanline overlay */
background-image: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.15) 2px,
rgba(0,0,0,0.15) 4px
);
box-shadow: 0 0 24px rgba(255,176,0,0.15), inset 0 0 40px rgba(0,0,0,0.4);
}
/* ── WebSocket status dot ────────────────────────────────────────────────── */
#ws-dot {
position: absolute;
top: 10px;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--red);
transition: background 0.3s;
}
#ws-dot.connected { background: var(--green); }
#ws-dot.polling { background: var(--amber-dim); }
#ws-dot.pulse { animation: pulse-dot 0.3s ease-out; }
@keyframes pulse-dot {
0% { transform: scale(1.8); opacity: 0.6; }
100% { transform: scale(1); opacity: 1; }
}
/* ── Llama container ─────────────────────────────────────────────────────── */
#llama-wrap {
position: relative;
width: 150px;
height: 175px;
display: flex;
align-items: center;
justify-content: center;
}
#llama {
width: 135px;
height: 160px;
}
/* SVG part colors */
.part { fill: var(--amber); stroke: none; }
.tail { stroke: var(--amber); fill: none; }
.hoof { fill: var(--amber-dk); }
.nostril { fill: var(--bg); }
.shine { fill: var(--bg); }
.mouth-closed { stroke: var(--bg); fill: none; }
.mouth-open { fill: var(--bg); }
.book { fill: var(--amber-dim); stroke: var(--amber-dk); stroke-width: 0.5; }
.book line { stroke: var(--amber); }
.sleep-eye { stroke: var(--bg); fill: none; }
/* ── State-based element visibility ─────────────────────────────────────── */
/* Default: open eyes, no accessories */
.sleep-eye { display: none; }
.mouth-open { display: none; }
.book { display: none; }
#zzz-wrap { display: none; }
#speech-bubble { display: none; }
#progress-wrap { display: none; }
/* sleeping */
body.state-sleeping .sleep-eye { display: block; }
body.state-sleeping .eye { display: none; }
body.state-sleeping .shine { display: none; }
body.state-sleeping #zzz-wrap { display: flex; }
/* processing_prompt */
body.state-processing_prompt .book { display: block; }
/* generating */
body.state-generating .mouth-open { display: block; }
body.state-generating .mouth-closed { display: none; }
body.state-generating #speech-bubble { display: flex; }
/* loading */
body.state-loading #progress-wrap { display: flex; }
/* processing_prompt */
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;
top: 8px;
right: 4px;
flex-direction: column;
align-items: flex-start;
gap: 2px;
pointer-events: none;
}
.z {
font-family: var(--font);
color: var(--amber);
animation: float-z 2.4s ease-in-out infinite;
opacity: 0;
}
.z1 { font-size: 9px; animation-delay: 0s; }
.z2 { font-size: 11px; animation-delay: 0.6s; }
.z3 { font-size: 13px; animation-delay: 1.2s; }
@keyframes float-z {
0% { opacity: 0; transform: translateY(0); }
20% { opacity: 1; }
80% { opacity: 0.8; }
100% { opacity: 0; transform: translateY(-28px); }
}
/* ── Speech bubble ───────────────────────────────────────────────────────── */
#speech-bubble {
position: absolute;
top: 10px;
right: 0;
background: var(--amber);
color: var(--bg);
border-radius: 8px;
padding: 4px 7px;
font-size: 14px;
gap: 1px;
pointer-events: none;
}
#speech-bubble::after {
content: '';
position: absolute;
bottom: -7px;
left: 10px;
border: 7px solid transparent;
border-top-color: var(--amber);
border-bottom: 0;
}
.dot {
animation: blink-dot 1.2s ease-in-out infinite;
opacity: 0;
}
.d1 { animation-delay: 0s; }
.d2 { animation-delay: 0.3s; }
.d3 { animation-delay: 0.6s; }
@keyframes blink-dot {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
/* ── State label ─────────────────────────────────────────────────────────── */
#state-label {
font-size: 8px;
color: var(--amber-dim);
letter-spacing: 2px;
text-transform: uppercase;
}
/* ── Progress bar ────────────────────────────────────────────────────────── */
#progress-wrap {
width: 100%;
flex-direction: column;
align-items: center;
gap: 4px;
}
#progress-bar {
width: 100%;
height: 8px;
border: 1px solid var(--amber-dim);
border-radius: 2px;
overflow: hidden;
}
#progress-fill {
height: 100%;
background: var(--amber);
width: 0%;
transition: width 0.4s ease;
}
#progress-label {
font-size: 8px;
color: var(--amber);
}
/* ── Resize handle ───────────────────────────────────────────────────────── */
#resize-handle {
position: absolute;
right: -6px;
top: 0;
bottom: 0;
width: 12px;
cursor: ew-resize;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.3;
transition: opacity 0.15s;
user-select: none;
z-index: 10;
}
#resize-handle:hover,
#resize-handle.dragging { opacity: 1; }
#resize-handle::after {
content: '';
width: 2px;
height: 48px;
background: var(--amber-dim);
border-radius: 1px;
box-shadow: -4px 0 0 var(--amber-dim), 4px 0 0 var(--amber-dim);
}
@media (orientation: portrait) {
#resize-handle { display: none; }
}
/* ── Stats panel ─────────────────────────────────────────────────────────── */
#stats-panel {
position: relative;
flex: 0 0 300px;
background: #0d0d06;
border: 3px solid var(--amber-dim);
border-radius: 12px;
padding: 1.2rem 1rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
box-shadow: 0 0 24px rgba(255,176,0,0.10);
}
@media (orientation: portrait) {
#stats-panel { width: 280px; flex: 0 0 auto; }
}
.stat-row {
display: flex;
flex-direction: column;
gap: 3px;
}
.sk {
font-size: 7px;
color: var(--amber-dk);
letter-spacing: 1px;
}
.sv {
font-size: 8px;
color: var(--amber);
word-break: break-all;
line-height: 1.5;
}
/* hide irrelevant rows per state */
body.state-offline #row-model,
body.state-offline #row-prompt,
body.state-offline #row-speed,
body.state-offline #row-tokens,
body.state-offline #row-load-stage { display: none; }
body.state-starting #row-prompt,
body.state-starting #row-speed,
body.state-starting #row-tokens,
body.state-starting #row-load-stage { display: none; }
body.state-loading #row-prompt,
body.state-loading #row-speed,
body.state-loading #row-tokens { display: none; }
body.state-idle #row-prompt,
body.state-idle #row-speed,
body.state-idle #row-tokens,
body.state-idle #row-load-stage { display: none; }
body.state-sleeping #row-prompt,
body.state-sleeping #row-speed,
body.state-sleeping #row-tokens,
body.state-sleeping #row-load-stage { display: none; }
body:not(.state-sleeping) #row-sleep-timer { display: none; }
body.state-waiting #row-prompt,
body.state-waiting #row-speed,
body.state-waiting #row-tokens,
body.state-waiting #row-load-stage { display: none; }
body.state-unloading #row-prompt,
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; }
body.state-generating #row-load-stage { display: none; }
/* ── Per-state SVG animations ────────────────────────────────────────────── */
/* sleeping: gentle breathe on body */
body.state-sleeping .body,
body.state-sleeping .neck,
body.state-sleeping .head,
body.state-sleeping .ear {
animation: breathe 3.5s ease-in-out infinite;
transform-origin: 50px 90px;
}
@keyframes breathe {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(1.03); }
}
/* idle: occasional ear twitch */
body.state-idle .right-ear {
animation: ear-twitch 5s ease-in-out infinite;
transform-origin: 62px 22px;
}
@keyframes ear-twitch {
0%, 85%, 100% { transform: rotate(15deg); }
90% { transform: rotate(5deg); }
95% { transform: rotate(22deg); }
}
/* waiting: both ears perk forward */
body.state-waiting .left-ear {
transform: rotate(-5deg);
transform-origin: 38px 22px;
transition: transform 0.3s;
}
body.state-waiting .right-ear {
transform: rotate(5deg);
transform-origin: 62px 22px;
transition: transform 0.3s;
}
/* loading: walk cycle on legs */
body.state-loading .leg-fl,
body.state-loading .leg-bl {
animation: walk-a 0.7s ease-in-out infinite;
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: 45px 100px;
}
@keyframes walk-a {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes walk-b {
0%, 100% { transform: translateY(-6px); }
50% { transform: translateY(0); }
}
/* processing_prompt: eyes move side to side (reading) */
body.state-processing_prompt .left-eye,
body.state-processing_prompt .right-eye,
body.state-processing_prompt .shine {
animation: read-eyes 2s ease-in-out infinite;
}
@keyframes read-eyes {
0%, 100% { transform: translateX(0); }
30% { transform: translateX(-1.5px); }
70% { transform: translateX(1.5px); }
}
/* generating: tail wag + mouth animation */
body.state-generating .tail {
animation: wag 0.45s ease-in-out infinite alternate;
transform-origin: 72px 82px;
}
body.state-generating .mouth-open {
animation: talk 0.3s ease-in-out infinite alternate;
transform-origin: 50px 39px;
}
@keyframes wag {
0% { transform: rotate(-18deg); }
100% { transform: rotate(18deg); }
}
@keyframes talk {
0% { transform: scaleY(0.4); }
100% { transform: scaleY(1); }
}
/* warming_up: yawn — jaw drops */
body.state-warming_up .mouth-open { display: block; }
body.state-warming_up .mouth-closed { display: none; }
body.state-warming_up .mouth-open {
animation: yawn 2.5s ease-in-out infinite;
transform-origin: 50px 39px;
}
@keyframes yawn {
0%, 20%, 100% { transform: scaleY(0.3); }
50%, 70% { transform: scaleY(1.4); }
}
/* unloading: whole llama fades and drifts right */
body.state-unloading #llama {
animation: unload 1.5s ease-in-out infinite alternate;
}
@keyframes unload {
0% { transform: translateX(0); opacity: 1; }
100% { transform: translateX(12px); opacity: 0.4; }
}

View File

@@ -37,10 +37,11 @@ class LoadingInfo:
@dataclass
class Metrics:
prompt_speed: Optional[float] = None # tokens/sec during prompt eval
prompt_tokens: Optional[int] = None # total prompt tokens
gen_speed: Optional[float] = None # tokens/sec during generation
n_decoded: Optional[int] = None # tokens decoded so far
prompt_speed: Optional[float] = None # tokens/sec during prompt eval
prompt_tokens: Optional[int] = None # total prompt tokens
prompt_progress: Optional[float] = None # 0.01.0 from live prompt timing lines
gen_speed: Optional[float] = None # tokens/sec during generation
n_decoded: Optional[int] = None # tokens decoded so far
@dataclass
@@ -50,6 +51,7 @@ class ServerState:
loading: Optional[LoadingInfo] = None
metrics: Metrics = field(default_factory=Metrics)
request_count: int = 0
sleeping_since: Optional[str] = None # ISO timestamp when sleeping started
def to_dict(self) -> dict:
return {
@@ -58,5 +60,174 @@ class ServerState:
"loading": asdict(self.loading) if self.loading else None,
"metrics": asdict(self.metrics),
"request_count": self.request_count,
"sleeping_since": self.sleeping_since,
"timestamp": datetime.now().isoformat(timespec="seconds"),
}
# ── Log patterns ─────────────────────────────────────────────────────────────
_SPAWN_RE = re.compile(r'spawning server instance with name=(\S+)')
_CMD_STATE_RE = re.compile(r'^cmd_child_to_router:state:(.+)$')
_ROUTER_LISTENING_RE = re.compile(r'llama_server: router server is listening')
_WARMUP_RE = re.compile(r'common_init_from_params: warming up')
_IDLE_RE = re.compile(r'update_slots: all slots are idle')
_PROXY_RE = re.compile(r'proxy_reques: proxying request')
_LAUNCH_RE = re.compile(r'slot launch_slot_:.*processing task')
_PROMPT_TIMING_RE = re.compile(
r'slot print_timing:.*prompt processing, n_tokens =\s+(\d+), progress = ([\d.]+),.*/ ([\d.]+) tokens per second'
)
_GEN_TIMING_RE = re.compile(
r'slot print_timing:.*n_decoded =\s+(\d+), tg =\s+([\d.]+) t/s'
)
_PROMPT_SUMMARY_RE = re.compile(
r'slot print_timing:.*prompt eval time =.*/ \s*(\d+) tokens.*?([\d.]+) tokens per second'
)
_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')
_CLIENT_CANCEL_RE = re.compile(r'http client error: Connection handling canceled')
_CANCEL_TASK_RE = re.compile(r'stop: cancel task')
# ── State machine ─────────────────────────────────────────────────────────────
class LogParser:
def __init__(self):
self._state = ServerState()
self._active_port: Optional[int] = None
@property
def state(self) -> ServerState:
return self._state
def feed(self, raw_line: str) -> Optional[ServerState]:
"""
Process one raw log line (with or without ts prefix).
Returns the updated ServerState if something meaningful changed, else None.
"""
line = strip_ts(raw_line)
if not line:
return None
kind, port, content = classify_line(line)
if kind == "child":
return self._handle_child(port, content)
return self._handle_router(content)
# ── Router-level lines ───────────────────────────────────────────────────
def _handle_router(self, content: str) -> Optional[ServerState]:
if _ROUTER_LISTENING_RE.search(content):
return self._set(state="starting")
if m := _SPAWN_RE.search(content):
self._state.model = m.group(1)
return None # wait for child's loading signal
if _PROXY_RE.search(content):
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")
if _CLIENT_CANCEL_RE.search(content):
return self._set(state="idle", loading=None, metrics=Metrics())
return None
# ── Child-process lines ──────────────────────────────────────────────────
def _handle_child(self, port: int, content: str) -> Optional[ServerState]:
self._active_port = port
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 _CANCEL_TASK_RE.search(content):
return self._set(state="idle", loading=None, metrics=Metrics())
if _WARMUP_RE.search(content):
return self._set(state="warming_up")
if _IDLE_RE.search(content):
return self._set(state="idle", loading=None, metrics=Metrics())
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=None if done else progress,
)
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,
prompt_tokens=self._state.metrics.prompt_tokens,
gen_speed=float(m.group(2)),
n_decoded=int(m.group(1)),
)
return self._set(state="generating", metrics=metrics)
if m := _PROMPT_SUMMARY_RE.search(content):
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(
state="idle",
request_count=self._state.request_count + 1,
loading=None,
metrics=Metrics(),
)
return None
# ── cmd_child_to_router:state JSON handler ───────────────────────────────
def _handle_cmd_state(self, json_str: str) -> Optional[ServerState]:
try:
data = json.loads(json_str)
except json.JSONDecodeError:
return None
s = data.get("state")
payload = data.get("payload")
if s == "loading" and payload:
# Some transition messages only carry {"stage":"name"} with no
# progress info — skip them; the next message has the full payload.
if not all(k in payload for k in ("stages", "current", "value")):
return None
loading = LoadingInfo(
stages=payload["stages"],
current=payload["current"],
progress=payload["value"],
)
return self._set(state="loading", loading=loading)
if s == "ready":
if payload and (model_id := payload.get("id")):
self._state.model = model_id
return self._set(state="idle", loading=None)
if s == "sleeping":
return self._set(
state="sleeping",
sleeping_since=datetime.now().isoformat(timespec="seconds"),
)
return None
# ── Helpers ──────────────────────────────────────────────────────────────
def _set(self, **kwargs) -> ServerState:
if "state" in kwargs and kwargs["state"] != "sleeping":
self._state.sleeping_since = None
for k, v in kwargs.items():
setattr(self._state, k, v)
return self._state

120
log_watcher.py Normal file
View File

@@ -0,0 +1,120 @@
import asyncio
import os
from pathlib import Path
from typing import Callable, Awaitable, Optional
class LogWatcher:
POLL_INTERVAL = 0.1
FILE_RETRY_INTERVAL = 1.0
SEED_BYTES = 8192 # read last ~8 KB on open to seed initial state
def __init__(
self,
path: str,
on_line: Callable[[str], Awaitable[None]],
on_offline: Optional[Callable[[], Awaitable[None]]] = None,
offline_timeout: float = 60.0,
reopen: bool = False,
):
self._path = Path(path)
self._on_line = on_line
self._on_offline = on_offline
self._offline_timeout = offline_timeout
self._reopen = reopen
self._task: Optional[asyncio.Task] = None
def start(self) -> asyncio.Task:
self._task = asyncio.create_task(self._run())
return self._task
def stop(self):
if self._task:
self._task.cancel()
async def _run(self):
offline_since: Optional[float] = None
if self._reopen:
await self._run_reopen()
else:
await self._run_keepopen()
async def _run_keepopen(self):
"""Default: keep file open and poll readline(). Works on local filesystems."""
offline_since: Optional[float] = None
while True:
try:
with open(self._path, 'r', errors='replace') as f:
f.seek(0, 2)
end = f.tell()
f.seek(max(0, end - self.SEED_BYTES))
if f.tell() > 0:
f.readline() # discard partial first line
offline_since = None
while True:
line = f.readline()
if line:
try:
await self._on_line(line)
except Exception:
pass # keep tailing even if a line causes a parser error
else:
await asyncio.sleep(self.POLL_INTERVAL)
except OSError:
now = asyncio.get_event_loop().time()
if offline_since is None:
offline_since = now
elif now - offline_since >= self._offline_timeout and self._on_offline:
await self._on_offline()
offline_since = None
await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout))
except asyncio.CancelledError:
return
async def _run_reopen(self):
"""Close and reopen each poll cycle. Use for network filesystems (e.g. sshfs)
that cache file content at open time and don't propagate remote appends to
held-open file descriptors."""
offline_since: Optional[float] = None
pos: int = -1 # -1 = not yet seeded
while True:
try:
with open(self._path, 'r', errors='replace') as f:
f.seek(0, 2)
end = f.tell()
if pos < 0:
f.seek(max(0, end - self.SEED_BYTES))
if f.tell() > 0:
f.readline() # discard partial first line
pos = f.tell()
elif end < pos:
pos = 0 # file truncated / rotated
f.seek(pos)
while True:
line = f.readline()
if line:
pos = f.tell()
try:
await self._on_line(line)
except Exception:
pass # keep tailing even if a line causes a parser error
else:
break
offline_since = None
await asyncio.sleep(self.POLL_INTERVAL)
except OSError:
now = asyncio.get_event_loop().time()
if offline_since is None:
offline_since = now
elif now - offline_since >= self._offline_timeout and self._on_offline:
await self._on_offline()
offline_since = None
await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout))
except asyncio.CancelledError:
return

2
pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

116
server.py Normal file
View File

@@ -0,0 +1,116 @@
import argparse
import asyncio
import json
import os
from pathlib import Path
from typing import Optional
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from log_parser import LogParser, ServerState
from log_watcher import LogWatcher
FRONTEND_DIR = Path(__file__).parent / "frontend"
# ── App factory ───────────────────────────────────────────────────────────────
def create_app(log_path: str, reopen_log: bool = False) -> FastAPI:
app = FastAPI()
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
clients: set[WebSocket] = set()
parser = LogParser()
last_state: dict = parser.state.to_dict()
@app.get("/")
async def index():
return FileResponse(FRONTEND_DIR / "index.html")
@app.get("/state")
async def get_state():
return last_state
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
await ws.accept()
clients.add(ws)
await ws.send_text(json.dumps(last_state))
try:
while True:
await ws.receive_text()
except WebSocketDisconnect:
clients.discard(ws)
async def broadcast(state: ServerState):
nonlocal last_state, clients
last_state = state.to_dict()
payload = json.dumps(last_state)
dead: set[WebSocket] = set()
for ws in list(clients):
try:
await ws.send_text(payload)
except Exception:
dead.add(ws)
clients -= dead
async def on_line(line: str):
result = parser.feed(line)
if result is not None:
await broadcast(result)
async def on_offline():
parser._state.state = "offline"
await broadcast(parser.state)
@app.on_event("startup")
async def startup():
watcher = LogWatcher(log_path, on_line, on_offline=on_offline, reopen=reopen_log)
watcher.start()
return app
# ── CLI entry point ───────────────────────────────────────────────────────────
def _config_bool(key: str) -> bool:
config_file = Path("config.json")
if config_file.exists():
return bool(json.loads(config_file.read_text()).get(key, False))
return False
def resolve_log_path(cli_log: Optional[str]) -> str:
path = cli_log or os.environ.get("LLAMAGOCHI_LOG")
if not path:
config_file = Path("config.json")
if config_file.exists():
data = json.loads(config_file.read_text())
path = data.get("log")
if not path:
raise SystemExit(
"Error: log file path required.\n"
" Use --log /path/to/llama-server.log\n"
" or set LLAMAGOCHI_LOG env var\n"
" or set \"log\" key in config.json"
)
return path
def main():
ap = argparse.ArgumentParser(description="Llamagochi — llama-server virtual pet")
ap.add_argument("--log", help="Path to llama-server log file")
ap.add_argument("--port", type=int, default=8080, help="HTTP port (default: 8080)")
ap.add_argument("--reopen-log", action="store_true",
help="Close and reopen log file each poll cycle (for sshfs/network filesystems)")
args = ap.parse_args()
log_path = resolve_log_path(args.log)
reopen_log = args.reopen_log or _config_bool("reopen_log")
app = create_app(log_path, reopen_log=reopen_log)
uvicorn.run(app, host="0.0.0.0", port=args.port)
if __name__ == "__main__":
main()

View File

@@ -93,3 +93,168 @@ def test_server_state_to_dict_generating():
assert d["metrics"]["gen_speed"] == 72.2
assert d["metrics"]["n_decoded"] == 218
assert d["request_count"] == 5
from log_parser import LogParser
# Helper: feed a list of raw lines (with ts prefix), return final state name
def feed_lines(lines):
p = LogParser()
last = None
for line in lines:
result = p.feed(line)
if result is not None:
last = result
return p.state.state, p.state
# ── Router startup ───────────────────────────────────────────────────────────
def test_router_startup():
state, _ = feed_lines([
"[2026-06-22 14:29:49] 0.00.076.839 I srv llama_server: router server is listening on http://0.0.0.0:8082"
])
assert state == "starting"
# ── Model spawn + loading progress ──────────────────────────────────────────
SPAWN_LINE = "[2026-06-22 14:31:59] 2.10.286.889 I srv load: spawning server instance with name=unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended on port 41803"
LOADING_START = '[2026-06-22 14:31:59] [41803] cmd_child_to_router:state:{"state":"loading","payload":{"stages":["text_model","mmproj_model"],"current":"text_model","value":0.0}}'
LOADING_MID = '[2026-06-22 14:32:05] [41803] cmd_child_to_router:state:{"state":"loading","payload":{"stages":["text_model","mmproj_model"],"current":"text_model","value":0.5}}'
LOADING_DONE = '[2026-06-22 14:32:08] [41803] cmd_child_to_router:state:{"state":"loading","payload":{"stages":["text_model","mmproj_model"],"current":"text_model","value":1.0}}'
WARMUP_LINE = "[2026-06-22 14:32:10] [41803] 0.11.107.736 I common_init_from_params: warming up the model with an empty run - please wait ..."
READY_LINE = '[2026-06-22 14:32:13] [41803] cmd_child_to_router:state:{"state":"ready","payload":{"id":"unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended","aliases":[],"tags":[],"object":"model","created":1782127933,"owned_by":"llamacpp","meta":{}}}'
IDLE_LINE = "[2026-06-22 14:32:13] [41803] 0.13.230.405 I srv update_slots: all slots are idle"
def test_model_loading_progress():
p = LogParser()
p.feed(SPAWN_LINE)
p.feed(LOADING_START)
assert p.state.state == "loading"
assert p.state.loading.progress == 0.0
assert p.state.loading.current == "text_model"
p.feed(LOADING_MID)
assert p.state.loading.progress == 0.5
p.feed(LOADING_DONE)
assert p.state.loading.progress == 1.0
def test_spawn_captures_model_name():
p = LogParser()
p.feed(SPAWN_LINE)
assert p.state.model == "unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended"
def test_warmup_state():
state, _ = feed_lines([SPAWN_LINE, LOADING_START, WARMUP_LINE])
assert state == "warming_up"
def test_ready_transitions_to_idle():
state, s = feed_lines([SPAWN_LINE, LOADING_START, WARMUP_LINE, READY_LINE])
assert state == "idle"
assert s.model == "unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended"
def test_idle_line():
state, _ = feed_lines([SPAWN_LINE, LOADING_START, READY_LINE, IDLE_LINE])
assert state == "idle"
# ── Request lifecycle ────────────────────────────────────────────────────────
PROXY_LINE = "[2026-06-22 14:32:13] 2.23.870.146 I srv proxy_reques: proxying request to model unsloth/gemma-4-31B-it:UD-Q8_K_XL-recommended on port 41803"
LAUNCH_LINE = "[2026-06-22 14:34:20] [41803] 0.30.889.639 I slot launch_slot_: id 0 | task 0 | processing task, is_child = 0"
PROMPT_TIMING = "[2026-06-22 14:33:17] [41803] 0.56.093.911 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 276, progress = 0.99, t = 4.90 s / 56.38 tokens per second"
GEN_TIMING = "[2026-06-22 14:33:20] [41803] 0.59.444.220 I slot print_timing: id 0 | task 0 | n_decoded = 156, tg = 51.94 t/s, tg_3s = 51.94 t/s"
RELEASE_LINE = "[2026-06-22 14:33:24] [41803] 1.02.743.373 I slot release: id 0 | task 0 | stop processing: n_tokens = 606, truncated = 0"
def test_proxy_sets_waiting():
state, _ = feed_lines([READY_LINE, IDLE_LINE, PROXY_LINE])
assert state == "waiting"
def test_launch_sets_processing_prompt():
state, _ = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE])
assert state == "processing_prompt"
def test_prompt_timing_updates_metrics():
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, PROMPT_TIMING])
assert s.state == "processing_prompt"
assert abs(s.metrics.prompt_speed - 56.38) < 0.01
assert s.metrics.prompt_tokens == 276
def test_gen_timing_sets_generating():
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, PROMPT_TIMING, GEN_TIMING])
assert s.state == "generating"
assert abs(s.metrics.gen_speed - 51.94) < 0.01
assert s.metrics.n_decoded == 156
# Short prompts never emit a live "prompt processing" line — only the end-of-slot
# summary fires, after generation has already started.
PROMPT_SUMMARY = "[2026-06-22 14:34:24] [41983] 0.34.388.097 I slot print_timing: id 0 | task 0 | prompt eval time = 425.83 ms / 11 tokens ( 38.71 ms per token, 25.83 tokens per second)"
def test_prompt_summary_captures_speed_during_generating():
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, GEN_TIMING, PROMPT_SUMMARY])
assert s.state == "generating" # state name unchanged
assert abs(s.metrics.prompt_speed - 25.83) < 0.01
assert s.metrics.prompt_tokens == 11
assert abs(s.metrics.gen_speed - 51.94) < 0.01 # gen metrics preserved
def test_release_increments_request_count():
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, GEN_TIMING, RELEASE_LINE])
assert s.state == "idle"
assert s.request_count == 1
assert s.metrics.gen_speed is None # cleared on release
def test_release_clears_metrics():
_, s = feed_lines([IDLE_LINE, PROXY_LINE, LAUNCH_LINE, GEN_TIMING, RELEASE_LINE])
assert s.metrics.prompt_speed is None
assert s.metrics.gen_speed is None
assert s.metrics.n_decoded is None
# ── Sleeping ─────────────────────────────────────────────────────────────────
SLEEPING_LINE = '[2026-06-22 15:23:11] [36417] cmd_child_to_router:state:{"state":"sleeping","payload":null}'
def test_sleeping_state():
state, _ = feed_lines([IDLE_LINE, SLEEPING_LINE])
assert state == "sleeping"
# ── Unloading ────────────────────────────────────────────────────────────────
UNLOAD_LINE = "[2026-06-22 14:32:19] 2.30.337.683 I srv unload_lru: models_max limit reached, removing LRU name=foo"
def test_unload_lru():
state, _ = feed_lines([IDLE_LINE, UNLOAD_LINE])
assert state == "unloading"
# ── Without ts prefix (single-instance mode) ────────────────────────────────
def test_no_ts_prefix():
p = LogParser()
p.feed("0.00.075.237 I srv llama_server: router server is listening on http://0.0.0.0:8082")
assert p.state.state == "starting"
def test_child_no_ts_prefix():
p = LogParser()
p.feed('[41803] cmd_child_to_router:state:{"state":"loading","payload":{"stages":["text_model"],"current":"text_model","value":0.3}}')
assert p.state.state == "loading"
assert p.state.loading.progress == 0.3

72
tests/test_log_watcher.py Normal file
View File

@@ -0,0 +1,72 @@
import asyncio
import os
import tempfile
import pytest
from log_watcher import LogWatcher
pytestmark = pytest.mark.asyncio
async def test_watcher_receives_new_lines():
with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f:
path = f.name
try:
received = []
async def on_line(line):
received.append(line.rstrip('\n'))
watcher = LogWatcher(path, on_line)
watcher.start()
await asyncio.sleep(0.05) # let watcher open and seek to end
with open(path, 'a') as f:
f.write("line one\n")
f.write("line two\n")
await asyncio.sleep(0.3) # wait for poll cycles
watcher.stop()
assert received == ["line one", "line two"]
finally:
os.unlink(path)
async def test_watcher_handles_missing_file_then_appears():
path = tempfile.mktemp(suffix='.log')
received = []
async def on_line(line):
received.append(line.rstrip('\n'))
watcher = LogWatcher(path, on_line)
watcher.start()
await asyncio.sleep(0.15) # file doesn't exist yet
with open(path, 'w') as f:
f.write("hello\n")
await asyncio.sleep(0.3)
watcher.stop()
# File appeared after watcher started — lines written before watcher opens
# will be consumed. This test just checks it doesn't crash.
assert isinstance(received, list)
if os.path.exists(path):
os.unlink(path)
async def test_watcher_calls_on_offline_after_timeout():
path = tempfile.mktemp(suffix='.log') # never created
offline_called = []
async def on_offline():
offline_called.append(True)
watcher = LogWatcher(path, lambda _: None, on_offline=on_offline, offline_timeout=0.2)
watcher.start()
await asyncio.sleep(0.5)
watcher.stop()
assert offline_called, "on_offline should have been called"