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 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 svSleepTimer = document.getElementById('sv-sleep-timer');
|
||||||
const svReqs = document.getElementById('sv-reqs');
|
const svReqs = document.getElementById('sv-reqs');
|
||||||
|
|
||||||
// ── State class management ────────────────────────────────────────────────
|
// ── State class management ────────────────────────────────────────────────
|
||||||
@@ -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
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>
|
<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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ class LogWatcher:
|
|||||||
while True:
|
while True:
|
||||||
line = f.readline()
|
line = f.readline()
|
||||||
if line:
|
if line:
|
||||||
|
try:
|
||||||
await self._on_line(line)
|
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()
|
||||||
|
try:
|
||||||
await self._on_line(line)
|
await self._on_line(line)
|
||||||
|
except Exception:
|
||||||
|
pass # keep tailing even if a line causes a parser error
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user