Replay now matches the robots and no longer cuts words: - read the turn to turnComplete, not generationComplete, and drain the socket before each send; breaking early truncated every sentence and left frames that the next turn mis-read as its own reply - accept a take only if the model's own transcript covers the text AND the audio is long enough to contain it (the transcript reports the full text even for a 0.8s clip) - pitch gate: reject an off-tone take and re-ask, per voice, using a pure-Python F0 estimator (no numpy on the host) - continuation: speak the words a voice skipped instead of retrying a line it stops on deterministically - fresh Live session per replay; delivery drifts as turns accumulate Live Gemini tab: browser talks to Gemini directly (the reverse proxy cannot upgrade a WebSocket), with a persona library - named personas, per-robot selection, built-ins that cannot be overwritten. Dashboard: records search + voice filter, log panel falls back to polling, sign-in history with CSV/JSON export, and JS errors now show on the page instead of silently blanking a tab.
107 lines
3.0 KiB
Python
107 lines
3.0 KiB
Python
"""WebSocket endpoint for real-time log streaming.
|
|
|
|
Clients connect to /ws/logs and receive live log lines from all modules.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
from collections import deque
|
|
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
|
|
from Project.Sanad.core.logger import set_ws_push
|
|
|
|
router = APIRouter()
|
|
|
|
MAX_WATCHERS = 50
|
|
|
|
# Ring buffer of recent log lines (shared across connections).
|
|
_recent: deque[str] = deque(maxlen=500)
|
|
_watchers: set[asyncio.Queue] = set()
|
|
_watchers_lock = threading.Lock()
|
|
|
|
|
|
_seq = 0 # total lines ever pushed; _recent holds the tail of this sequence
|
|
|
|
|
|
def recent_since(cursor: int, limit: int = 300) -> tuple[list[str], int]:
|
|
"""Lines newer than `cursor`, plus the new cursor.
|
|
|
|
Backs the HTTP polling fallback: this deployment sits behind an Apache
|
|
`[P]` rewrite that cannot upgrade WebSockets (verified — the same
|
|
handshake returns 101 straight to uvicorn and 404 through the proxy), so
|
|
the browser can never hold /ws/logs open and needs to poll instead.
|
|
|
|
A cursor of -1, or one so old its lines have already been evicted from the
|
|
ring, returns the most recent `limit` lines.
|
|
"""
|
|
lines = list(_recent)
|
|
first_seq = _seq - len(lines) + 1 # sequence number of lines[0]
|
|
if cursor < 0 or cursor < first_seq - 1:
|
|
tail = lines[-limit:]
|
|
return tail, _seq
|
|
skip = cursor - (first_seq - 1)
|
|
fresh = lines[skip:][:limit]
|
|
return fresh, cursor + len(fresh)
|
|
|
|
|
|
def push_log_line(line: str):
|
|
"""Called from the logging system to feed new lines.
|
|
|
|
May be called from any thread (logging is multi-threaded), so we
|
|
snapshot the watchers under a lock before iterating.
|
|
"""
|
|
global _seq
|
|
_seq += 1
|
|
_recent.append(line)
|
|
with _watchers_lock:
|
|
snapshot = list(_watchers)
|
|
for q in snapshot:
|
|
try:
|
|
q.put_nowait(line)
|
|
except asyncio.QueueFull:
|
|
# Drop on overflow rather than block — logs are not critical data
|
|
pass
|
|
|
|
|
|
# Register with the logger so all log records are pushed to WS clients.
|
|
# Wrap so a logger registration failure doesn't break Dashboard import.
|
|
try:
|
|
set_ws_push(push_log_line)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@router.websocket("/ws/logs")
|
|
async def log_ws(ws: WebSocket):
|
|
await ws.accept()
|
|
|
|
with _watchers_lock:
|
|
if len(_watchers) >= MAX_WATCHERS:
|
|
await ws.close(code=1013, reason="Too many log watchers")
|
|
return
|
|
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=200)
|
|
_watchers.add(queue)
|
|
|
|
try:
|
|
# Send recent history
|
|
for line in list(_recent):
|
|
await ws.send_text(line)
|
|
|
|
while True:
|
|
line = await queue.get()
|
|
await ws.send_text(line)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception:
|
|
# Any other error closes the connection cleanly
|
|
try:
|
|
await ws.close()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
with _watchers_lock:
|
|
_watchers.discard(queue)
|