Files
Llamagochi/server.py
Vlad Doloman 311819b51c fix: sshfs polling, WebSocket broadcast, prompt progress bar, resizable stats panel
- 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 <noreply@anthropic.com>
2026-06-22 21:02:34 +03:00

113 lines
3.7 KiB
Python

import argparse
import asyncio
import json
import os
from pathlib import Path
from typing import Optional
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from log_parser import LogParser, ServerState
from log_watcher import LogWatcher
FRONTEND_DIR = Path(__file__).parent / "frontend"
# ── App factory ───────────────────────────────────────────────────────────────
def create_app(log_path: str, reopen_log: bool = False) -> FastAPI:
app = FastAPI()
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
clients: set[WebSocket] = set()
parser = LogParser()
last_state: dict = parser.state.to_dict()
@app.get("/")
async def index():
return FileResponse(FRONTEND_DIR / "index.html")
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
await ws.accept()
clients.add(ws)
await ws.send_text(json.dumps(last_state))
try:
while True:
await ws.receive_text()
except WebSocketDisconnect:
clients.discard(ws)
async def broadcast(state: ServerState):
nonlocal last_state, clients
last_state = state.to_dict()
payload = json.dumps(last_state)
dead: set[WebSocket] = set()
for ws in list(clients):
try:
await ws.send_text(payload)
except Exception:
dead.add(ws)
clients -= dead
async def on_line(line: str):
result = parser.feed(line)
if result is not None:
await broadcast(result)
async def on_offline():
parser._state.state = "offline"
await broadcast(parser.state)
@app.on_event("startup")
async def startup():
watcher = LogWatcher(log_path, on_line, on_offline=on_offline, reopen=reopen_log)
watcher.start()
return app
# ── 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:
config_file = Path("config.json")
if config_file.exists():
data = json.loads(config_file.read_text())
path = data.get("log")
if not path:
raise SystemExit(
"Error: log file path required.\n"
" Use --log /path/to/llama-server.log\n"
" or set LLAMAGOCHI_LOG env var\n"
" or set \"log\" key in config.json"
)
return path
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)
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)
if __name__ == "__main__":
main()