From 311819b51c5e8bf95891afa5ce0da0b7d7bb8eef Mon Sep 17 00:00:00 2001 From: Vlad Doloman Date: Mon, 22 Jun 2026 21:02:34 +0300 Subject: [PATCH] fix: sshfs polling, WebSocket broadcast, prompt progress bar, resizable stats panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/app.js | 47 +++++++++++++++++++++++++++++++++---- frontend/index.html | 1 + frontend/style.css | 36 ++++++++++++++++++++++++++++- log_parser.py | 14 +++++++----- log_watcher.py | 56 ++++++++++++++++++++++++++++++++++++++++++++- server.py | 20 ++++++++++++---- 6 files changed, 157 insertions(+), 17 deletions(-) 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)