diff --git a/frontend/app.js b/frontend/app.js
index 1a36543..66cccfb 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -2,8 +2,10 @@
'use strict';
// ── DOM refs ──────────────────────────────────────────────────────────────
- const body = document.body;
- const wsDot = document.getElementById('ws-dot');
+ const body = document.body;
+ const wsDot = document.getElementById('ws-dot');
+ const statsPanel = document.getElementById('stats-panel');
+ const resizeHandle = document.getElementById('resize-handle');
const stateLabel = document.getElementById('state-label');
const progressFill = document.getElementById('progress-fill');
const progressLabel = document.getElementById('progress-label');
@@ -42,15 +44,20 @@
stateLabel.textContent = state.replace('_', ' ').toUpperCase();
svState.textContent = state.replace('_', ' ');
- svModel.textContent = model ? truncate(model, 22) : '—';
+ svModel.textContent = model || '—';
svReqs.textContent = request_count ?? 0;
- // Loading progress
+ // 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;
+ } 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 = '';
@@ -72,6 +79,38 @@
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);
+
// ── WebSocket with auto-reconnect ─────────────────────────────────────────
let ws = null;
let retryDelay = 1000;
diff --git a/frontend/index.html b/frontend/index.html
index 3105bc2..33743ae 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -116,6 +116,7 @@
+
MODEL
—
diff --git a/frontend/style.css b/frontend/style.css
index e63bf0b..ff4bf2a 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -139,6 +139,9 @@ 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; }
@@ -237,9 +240,40 @@ body.state-offline #llama { opacity: 0.15; }
color: var(--amber-dim);
}
+/* ── 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 {
- flex: 0 0 220px;
+ position: relative;
+ flex: 0 0 300px;
background: #0d0d06;
border: 3px solid var(--amber-dim);
border-radius: 12px;
diff --git a/log_parser.py b/log_parser.py
index 1cceabc..843fe31 100644
--- a/log_parser.py
+++ b/log_parser.py
@@ -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.0–1.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
@@ -72,7 +73,7 @@ _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+),.*/ ([\d.]+) tokens per second'
+ 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'
@@ -137,8 +138,9 @@ class LogParser:
return self._set(state="processing_prompt")
if m := _PROMPT_TIMING_RE.search(content):
metrics = Metrics(
- prompt_speed=float(m.group(2)),
+ prompt_speed=float(m.group(3)),
prompt_tokens=int(m.group(1)),
+ prompt_progress=float(m.group(2)),
gen_speed=self._state.metrics.gen_speed,
n_decoded=self._state.metrics.n_decoded,
)
diff --git a/log_watcher.py b/log_watcher.py
index df865ad..fdf6282 100644
--- a/log_watcher.py
+++ b/log_watcher.py
@@ -15,11 +15,13 @@ class LogWatcher:
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:
@@ -33,6 +35,15 @@ class LogWatcher:
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:
@@ -54,7 +65,50 @@ class LogWatcher:
offline_since = now
elif now - offline_since >= self._offline_timeout and self._on_offline:
await self._on_offline()
- offline_since = None # reset so we don't spam
+ 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()
+ await self._on_line(line)
+ 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
diff --git a/server.py b/server.py
index 079aef9..5e825e0 100644
--- a/server.py
+++ b/server.py
@@ -17,7 +17,7 @@ FRONTEND_DIR = Path(__file__).parent / "frontend"
# ── App factory ───────────────────────────────────────────────────────────────
-def create_app(log_path: str) -> FastAPI:
+def create_app(log_path: str, reopen_log: bool = False) -> FastAPI:
app = FastAPI()
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
@@ -41,11 +41,11 @@ def create_app(log_path: str) -> FastAPI:
clients.discard(ws)
async def broadcast(state: ServerState):
- nonlocal last_state
+ nonlocal last_state, clients
last_state = state.to_dict()
payload = json.dumps(last_state)
dead: set[WebSocket] = set()
- for ws in clients:
+ for ws in list(clients):
try:
await ws.send_text(payload)
except Exception:
@@ -63,7 +63,7 @@ def create_app(log_path: str) -> FastAPI:
@app.on_event("startup")
async def startup():
- watcher = LogWatcher(log_path, on_line, on_offline=on_offline)
+ watcher = LogWatcher(log_path, on_line, on_offline=on_offline, reopen=reopen_log)
watcher.start()
return app
@@ -71,6 +71,13 @@ def create_app(log_path: str) -> FastAPI:
# ── 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:
@@ -92,9 +99,12 @@ 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)
- app = create_app(log_path)
+ 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)