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>
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
// ── DOM refs ──────────────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────────────
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
const wsDot = document.getElementById('ws-dot');
|
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 stateLabel = document.getElementById('state-label');
|
||||||
const progressFill = document.getElementById('progress-fill');
|
const progressFill = document.getElementById('progress-fill');
|
||||||
const progressLabel = document.getElementById('progress-label');
|
const progressLabel = document.getElementById('progress-label');
|
||||||
@@ -42,15 +44,20 @@
|
|||||||
|
|
||||||
stateLabel.textContent = state.replace('_', ' ').toUpperCase();
|
stateLabel.textContent = state.replace('_', ' ').toUpperCase();
|
||||||
svState.textContent = state.replace('_', ' ');
|
svState.textContent = state.replace('_', ' ');
|
||||||
svModel.textContent = model ? truncate(model, 22) : '—';
|
svModel.textContent = model || '—';
|
||||||
svReqs.textContent = request_count ?? 0;
|
svReqs.textContent = request_count ?? 0;
|
||||||
|
|
||||||
// Loading progress
|
// Progress bar: model loading or prompt processing
|
||||||
if (loading) {
|
if (loading) {
|
||||||
const pct = Math.round((loading.progress ?? 0) * 100);
|
const pct = Math.round((loading.progress ?? 0) * 100);
|
||||||
progressFill.style.width = pct + '%';
|
progressFill.style.width = pct + '%';
|
||||||
progressLabel.textContent = loading.current + ' ' + pct + '%';
|
progressLabel.textContent = loading.current + ' ' + pct + '%';
|
||||||
svLoadStage.textContent = loading.current;
|
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 {
|
} else {
|
||||||
progressFill.style.width = '0%';
|
progressFill.style.width = '0%';
|
||||||
progressLabel.textContent = '';
|
progressLabel.textContent = '';
|
||||||
@@ -72,6 +79,38 @@
|
|||||||
wsDot.classList.add('pulse');
|
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 ─────────────────────────────────────────
|
// ── WebSocket with auto-reconnect ─────────────────────────────────────────
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let retryDelay = 1000;
|
let retryDelay = 1000;
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
|
|
||||||
<!-- ── Stats panel ────────────────────────────────────────────── -->
|
<!-- ── Stats panel ────────────────────────────────────────────── -->
|
||||||
<div id="stats-panel">
|
<div id="stats-panel">
|
||||||
|
<div id="resize-handle" title="Drag to resize"></div>
|
||||||
<div class="stat-row" id="row-model">
|
<div class="stat-row" id="row-model">
|
||||||
<span class="sk">MODEL</span>
|
<span class="sk">MODEL</span>
|
||||||
<span class="sv" id="sv-model">—</span>
|
<span class="sv" id="sv-model">—</span>
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ body.state-generating #speech-bubble { display: flex; }
|
|||||||
/* loading */
|
/* loading */
|
||||||
body.state-loading #progress-wrap { display: flex; }
|
body.state-loading #progress-wrap { display: flex; }
|
||||||
|
|
||||||
|
/* processing_prompt */
|
||||||
|
body.state-processing_prompt #progress-wrap { display: flex; }
|
||||||
|
|
||||||
/* offline */
|
/* offline */
|
||||||
body.state-offline #llama { opacity: 0.15; }
|
body.state-offline #llama { opacity: 0.15; }
|
||||||
|
|
||||||
@@ -237,9 +240,40 @@ body.state-offline #llama { opacity: 0.15; }
|
|||||||
color: var(--amber-dim);
|
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 ─────────────────────────────────────────────────────────── */
|
||||||
#stats-panel {
|
#stats-panel {
|
||||||
flex: 0 0 220px;
|
position: relative;
|
||||||
|
flex: 0 0 300px;
|
||||||
background: #0d0d06;
|
background: #0d0d06;
|
||||||
border: 3px solid var(--amber-dim);
|
border: 3px solid var(--amber-dim);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class LoadingInfo:
|
|||||||
class Metrics:
|
class Metrics:
|
||||||
prompt_speed: Optional[float] = None # tokens/sec during prompt eval
|
prompt_speed: Optional[float] = None # tokens/sec during prompt eval
|
||||||
prompt_tokens: Optional[int] = None # total prompt tokens
|
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
|
gen_speed: Optional[float] = None # tokens/sec during generation
|
||||||
n_decoded: Optional[int] = None # tokens decoded so far
|
n_decoded: Optional[int] = None # tokens decoded so far
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ _IDLE_RE = re.compile(r'update_slots: all slots are idle')
|
|||||||
_PROXY_RE = re.compile(r'proxy_reques: proxying request')
|
_PROXY_RE = re.compile(r'proxy_reques: proxying request')
|
||||||
_LAUNCH_RE = re.compile(r'slot launch_slot_:.*processing task')
|
_LAUNCH_RE = re.compile(r'slot launch_slot_:.*processing task')
|
||||||
_PROMPT_TIMING_RE = re.compile(
|
_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(
|
_GEN_TIMING_RE = re.compile(
|
||||||
r'slot print_timing:.*n_decoded =\s+(\d+), tg =\s+([\d.]+) t/s'
|
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")
|
return self._set(state="processing_prompt")
|
||||||
if m := _PROMPT_TIMING_RE.search(content):
|
if m := _PROMPT_TIMING_RE.search(content):
|
||||||
metrics = Metrics(
|
metrics = Metrics(
|
||||||
prompt_speed=float(m.group(2)),
|
prompt_speed=float(m.group(3)),
|
||||||
prompt_tokens=int(m.group(1)),
|
prompt_tokens=int(m.group(1)),
|
||||||
|
prompt_progress=float(m.group(2)),
|
||||||
gen_speed=self._state.metrics.gen_speed,
|
gen_speed=self._state.metrics.gen_speed,
|
||||||
n_decoded=self._state.metrics.n_decoded,
|
n_decoded=self._state.metrics.n_decoded,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ class LogWatcher:
|
|||||||
on_line: Callable[[str], Awaitable[None]],
|
on_line: Callable[[str], Awaitable[None]],
|
||||||
on_offline: Optional[Callable[[], Awaitable[None]]] = None,
|
on_offline: Optional[Callable[[], Awaitable[None]]] = None,
|
||||||
offline_timeout: float = 60.0,
|
offline_timeout: float = 60.0,
|
||||||
|
reopen: bool = False,
|
||||||
):
|
):
|
||||||
self._path = Path(path)
|
self._path = Path(path)
|
||||||
self._on_line = on_line
|
self._on_line = on_line
|
||||||
self._on_offline = on_offline
|
self._on_offline = on_offline
|
||||||
self._offline_timeout = offline_timeout
|
self._offline_timeout = offline_timeout
|
||||||
|
self._reopen = reopen
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
def start(self) -> asyncio.Task:
|
def start(self) -> asyncio.Task:
|
||||||
@@ -33,6 +35,15 @@ class LogWatcher:
|
|||||||
async def _run(self):
|
async def _run(self):
|
||||||
offline_since: Optional[float] = None
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
with open(self._path, 'r', errors='replace') as f:
|
with open(self._path, 'r', errors='replace') as f:
|
||||||
@@ -54,7 +65,50 @@ class LogWatcher:
|
|||||||
offline_since = now
|
offline_since = now
|
||||||
elif now - offline_since >= self._offline_timeout and self._on_offline:
|
elif now - offline_since >= self._offline_timeout and self._on_offline:
|
||||||
await 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))
|
await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout))
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
|
|||||||
20
server.py
20
server.py
@@ -17,7 +17,7 @@ FRONTEND_DIR = Path(__file__).parent / "frontend"
|
|||||||
|
|
||||||
# ── App factory ───────────────────────────────────────────────────────────────
|
# ── App factory ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_app(log_path: str) -> FastAPI:
|
def create_app(log_path: str, reopen_log: bool = False) -> FastAPI:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||||
|
|
||||||
@@ -41,11 +41,11 @@ def create_app(log_path: str) -> FastAPI:
|
|||||||
clients.discard(ws)
|
clients.discard(ws)
|
||||||
|
|
||||||
async def broadcast(state: ServerState):
|
async def broadcast(state: ServerState):
|
||||||
nonlocal last_state
|
nonlocal last_state, clients
|
||||||
last_state = state.to_dict()
|
last_state = state.to_dict()
|
||||||
payload = json.dumps(last_state)
|
payload = json.dumps(last_state)
|
||||||
dead: set[WebSocket] = set()
|
dead: set[WebSocket] = set()
|
||||||
for ws in clients:
|
for ws in list(clients):
|
||||||
try:
|
try:
|
||||||
await ws.send_text(payload)
|
await ws.send_text(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -63,7 +63,7 @@ def create_app(log_path: str) -> FastAPI:
|
|||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def 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()
|
watcher.start()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
@@ -71,6 +71,13 @@ def create_app(log_path: str) -> FastAPI:
|
|||||||
|
|
||||||
# ── CLI entry point ───────────────────────────────────────────────────────────
|
# ── 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:
|
def resolve_log_path(cli_log: Optional[str]) -> str:
|
||||||
path = cli_log or os.environ.get("LLAMAGOCHI_LOG")
|
path = cli_log or os.environ.get("LLAMAGOCHI_LOG")
|
||||||
if not path:
|
if not path:
|
||||||
@@ -92,9 +99,12 @@ def main():
|
|||||||
ap = argparse.ArgumentParser(description="Llamagochi — llama-server virtual pet")
|
ap = argparse.ArgumentParser(description="Llamagochi — llama-server virtual pet")
|
||||||
ap.add_argument("--log", help="Path to llama-server log file")
|
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("--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()
|
args = ap.parse_args()
|
||||||
log_path = resolve_log_path(args.log)
|
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)
|
uvicorn.run(app, host="0.0.0.0", port=args.port)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user