Compare commits
5 Commits
8712931c23
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b3c945ee | ||
|
|
a5e59df624 | ||
|
|
7bda83998e | ||
|
|
e2996ff0b3 | ||
|
|
ce9b2d73f7 |
121
README.md
Normal file
121
README.md
Normal 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 0–100%) |
|
||||
| `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.
|
||||
@@ -15,6 +15,7 @@
|
||||
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 ────────────────────────────────────────────────
|
||||
@@ -47,7 +48,7 @@
|
||||
}
|
||||
|
||||
function update(msg) {
|
||||
const { state, model, loading, metrics, request_count } = msg;
|
||||
const { state, model, loading, metrics, request_count, sleeping_since } = msg;
|
||||
|
||||
applyState(state);
|
||||
|
||||
@@ -58,10 +59,20 @@
|
||||
|
||||
// Progress bar: model loading or prompt processing
|
||||
if (loading) {
|
||||
const pct = Math.round((loading.progress ?? 0) * 100);
|
||||
progressFill.style.width = pct + '%';
|
||||
progressLabel.textContent = loading.current + ' ' + pct + '%';
|
||||
svLoadStage.textContent = loading.current;
|
||||
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 + '%';
|
||||
@@ -82,6 +93,13 @@
|
||||
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
|
||||
@@ -120,6 +138,40 @@
|
||||
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;
|
||||
|
||||
19
frontend/favicon.svg
Normal file
19
frontend/favicon.svg
Normal 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 |
@@ -6,6 +6,7 @@
|
||||
<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">
|
||||
@@ -146,6 +147,10 @@
|
||||
<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>
|
||||
|
||||
@@ -245,8 +245,8 @@ body.state-error #llama { animation: error-flash 1s ease-in-out infinite; }
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
#progress-label {
|
||||
font-size: 7px;
|
||||
color: var(--amber-dim);
|
||||
font-size: 8px;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ── Resize handle ───────────────────────────────────────────────────────── */
|
||||
@@ -340,6 +340,8 @@ 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,
|
||||
|
||||
@@ -51,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 {
|
||||
@@ -59,6 +60,7 @@ 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"),
|
||||
}
|
||||
|
||||
@@ -200,6 +202,10 @@ class LogParser:
|
||||
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"],
|
||||
@@ -211,12 +217,17 @@ class LogParser:
|
||||
self._state.model = model_id
|
||||
return self._set(state="idle", loading=None)
|
||||
if s == "sleeping":
|
||||
return self._set(state="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
|
||||
|
||||
@@ -56,7 +56,10 @@ class LogWatcher:
|
||||
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:
|
||||
@@ -96,7 +99,10 @@ class LogWatcher:
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user