Compare commits

..

5 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
7 changed files with 228 additions and 12 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.

View File

@@ -14,8 +14,9 @@
const svPrompt = document.getElementById('sv-prompt'); const svPrompt = document.getElementById('sv-prompt');
const svSpeed = document.getElementById('sv-speed'); const svSpeed = document.getElementById('sv-speed');
const svTokens = document.getElementById('sv-tokens'); const svTokens = document.getElementById('sv-tokens');
const svLoadStage = document.getElementById('sv-load-stage'); const svLoadStage = document.getElementById('sv-load-stage');
const svReqs = document.getElementById('sv-reqs'); const svSleepTimer = document.getElementById('sv-sleep-timer');
const svReqs = document.getElementById('sv-reqs');
// ── State class management ──────────────────────────────────────────────── // ── State class management ────────────────────────────────────────────────
const ALL_STATES = [ const ALL_STATES = [
@@ -47,7 +48,7 @@
} }
function update(msg) { function update(msg) {
const { state, model, loading, metrics, request_count } = msg; const { state, model, loading, metrics, request_count, sleeping_since } = msg;
applyState(state); applyState(state);
@@ -58,10 +59,20 @@
// Progress bar: model loading or prompt processing // Progress bar: model loading or prompt processing
if (loading) { if (loading) {
const pct = Math.round((loading.progress ?? 0) * 100); const stageName = (loading.current ?? '').replace(/_/g, ' ');
progressFill.style.width = pct + '%'; const stageIdx = (loading.stages ?? []).indexOf(loading.current);
progressLabel.textContent = loading.current + ' ' + pct + '%'; const totalStages = (loading.stages ?? []).length;
svLoadStage.textContent = loading.current; 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) { } else if (state === 'processing_prompt' && metrics && metrics.prompt_progress != null) {
const pct = Math.round(metrics.prompt_progress * 100); const pct = Math.round(metrics.prompt_progress * 100);
progressFill.style.width = pct + '%'; progressFill.style.width = pct + '%';
@@ -82,6 +93,13 @@
svPrompt.textContent = svSpeed.textContent = svTokens.textContent = '—'; svPrompt.textContent = svSpeed.textContent = svTokens.textContent = '—';
} }
// Sleep timer
if (state === 'sleeping' && sleeping_since) {
startSleepTimer(sleeping_since);
} else {
stopSleepTimer();
}
// Pulse dot on message // Pulse dot on message
wsDot.classList.remove('pulse'); wsDot.classList.remove('pulse');
void wsDot.offsetWidth; // force reflow to restart animation void wsDot.offsetWidth; // force reflow to restart animation
@@ -120,6 +138,40 @@
document.addEventListener('touchmove', e => { if (dragging) { e.preventDefault(); onDrag(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); 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 ───────────────────────────────────────────────── // ── HTTP polling fallback ─────────────────────────────────────────────────
let pollTimer = null; let pollTimer = null;
const POLL_MS = 2000; const POLL_MS = 2000;

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

View File

@@ -6,6 +6,7 @@
<title>Llamagochi</title> <title>Llamagochi</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet"> <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"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body class="state-offline"> <body class="state-offline">
@@ -146,6 +147,10 @@
<span class="sk">STAGE</span> <span class="sk">STAGE</span>
<span class="sv" id="sv-load-stage"></span> <span class="sv" id="sv-load-stage"></span>
</div> </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"> <div class="stat-row" id="row-reqs">
<span class="sk">REQS</span> <span class="sk">REQS</span>
<span class="sv" id="sv-reqs">0</span> <span class="sv" id="sv-reqs">0</span>

View File

@@ -245,8 +245,8 @@ body.state-error #llama { animation: error-flash 1s ease-in-out infinite; }
transition: width 0.4s ease; transition: width 0.4s ease;
} }
#progress-label { #progress-label {
font-size: 7px; font-size: 8px;
color: var(--amber-dim); color: var(--amber);
} }
/* ── Resize handle ───────────────────────────────────────────────────────── */ /* ── Resize handle ───────────────────────────────────────────────────────── */
@@ -340,6 +340,8 @@ body.state-sleeping #row-speed,
body.state-sleeping #row-tokens, body.state-sleeping #row-tokens,
body.state-sleeping #row-load-stage { display: none; } 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-prompt,
body.state-waiting #row-speed, body.state-waiting #row-speed,
body.state-waiting #row-tokens, body.state-waiting #row-tokens,

View File

@@ -51,6 +51,7 @@ class ServerState:
loading: Optional[LoadingInfo] = None loading: Optional[LoadingInfo] = None
metrics: Metrics = field(default_factory=Metrics) metrics: Metrics = field(default_factory=Metrics)
request_count: int = 0 request_count: int = 0
sleeping_since: Optional[str] = None # ISO timestamp when sleeping started
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -59,6 +60,7 @@ class ServerState:
"loading": asdict(self.loading) if self.loading else None, "loading": asdict(self.loading) if self.loading else None,
"metrics": asdict(self.metrics), "metrics": asdict(self.metrics),
"request_count": self.request_count, "request_count": self.request_count,
"sleeping_since": self.sleeping_since,
"timestamp": datetime.now().isoformat(timespec="seconds"), "timestamp": datetime.now().isoformat(timespec="seconds"),
} }
@@ -200,6 +202,10 @@ class LogParser:
payload = data.get("payload") payload = data.get("payload")
if s == "loading" and 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( loading = LoadingInfo(
stages=payload["stages"], stages=payload["stages"],
current=payload["current"], current=payload["current"],
@@ -211,12 +217,17 @@ class LogParser:
self._state.model = model_id self._state.model = model_id
return self._set(state="idle", loading=None) return self._set(state="idle", loading=None)
if s == "sleeping": if s == "sleeping":
return self._set(state="sleeping") return self._set(
state="sleeping",
sleeping_since=datetime.now().isoformat(timespec="seconds"),
)
return None return None
# ── Helpers ────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────
def _set(self, **kwargs) -> ServerState: def _set(self, **kwargs) -> ServerState:
if "state" in kwargs and kwargs["state"] != "sleeping":
self._state.sleeping_since = None
for k, v in kwargs.items(): for k, v in kwargs.items():
setattr(self._state, k, v) setattr(self._state, k, v)
return self._state return self._state

View File

@@ -56,7 +56,10 @@ class LogWatcher:
while True: while True:
line = f.readline() line = f.readline()
if line: if line:
await self._on_line(line) try:
await self._on_line(line)
except Exception:
pass # keep tailing even if a line causes a parser error
else: else:
await asyncio.sleep(self.POLL_INTERVAL) await asyncio.sleep(self.POLL_INTERVAL)
except OSError: except OSError:
@@ -96,7 +99,10 @@ class LogWatcher:
line = f.readline() line = f.readline()
if line: if line:
pos = f.tell() pos = f.tell()
await self._on_line(line) try:
await self._on_line(line)
except Exception:
pass # keep tailing even if a line causes a parser error
else: else:
break break