Files
VoiceStudio/backend/api/routers/events.py
T
Palash Debnath 34610ca091 feat: real-time WebSocket event bus + sidebar reactivity fixes (#27)
## Core Infrastructure
- Add backend event bus (core/event_bus.py) — in-memory pub/sub with
  emit(), subscribe(), unsubscribe()
- Add WebSocket endpoint /ws/events (api/routers/events.py) with 25s
  keepalive pings and auto-cleanup on disconnect
- Add frontend hook useRealtimeEvents.js — single WS connection with
  exponential backoff reconnect (2s→60s)

## Backend Event Integration
- projects.py: emit on create/update/delete
- profiles.py: emit on create/update/lock/unlock/delete
- dub_core.py: emit on clear/delete history
- dub_pipeline.py: emit on save_job (every pipeline write)
- exports.py: emit on export/record
- generation.py: emit on generate/clear/delete
- gallery.py: emit on save-as-profile/to-profile

## Frontend Improvements
- Replace 45s polling interval with instant WS-based invalidation
- Fix critical bug: apiModelStatus was undefined, causing loadAll()
  to loop forever — sidebar data never loaded on startup
- Add websockets to main deps (was optional, got removed by uv sync)
- Reduce model/status polling from 5s to 10s, disable background
  polling for logs
- Add ReadinessChecklist and FloatingPill components
- Default UI scale changed from S (1.0) to M (1.3)

## Dependencies
- Add websockets>=16.0 to main dependencies for uvicorn WS support

Closes #3 (native desktop app exists via Tauri)
Closes #5 (Dockerfile already uses root bun.lock)
Resolves #26 (Triton workaround documented)
2026-04-27 22:16:28 +05:30

53 lines
1.8 KiB
Python

"""WebSocket endpoint for real-time sidebar events.
A single ``/ws/events`` connection replaces all sidebar polling. The
frontend connects once and receives JSON messages like:
{"kind": "projects", "ts": 1714200000.0}
{"kind": "profiles", "ts": 1714200001.2, "id": "abc123"}
On each message the frontend invalidates the matching TanStack Query
cache key, which triggers a single targeted refetch.
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from core import event_bus
router = APIRouter()
logger = logging.getLogger("omnivoice.events")
@router.websocket("/ws/events")
async def ws_events(ws: WebSocket):
"""Fan-out event stream for sidebar reactivity.
Protocol:
- Server → Client: JSON event dicts (``kind``, ``ts``, optional fields)
- Client → Server: ping/pong only (no app-level messages expected)
- Server sends ``{"kind": "ping"}`` every 25 s as a keepalive
"""
await ws.accept()
q = await event_bus.subscribe()
logger.info("WS client connected (%d total)", len(event_bus._listeners))
try:
while True:
# Wait for an event or send a keepalive ping every 25s
try:
event_str = await asyncio.wait_for(q.get(), timeout=25.0)
await ws.send_text(event_str)
except asyncio.TimeoutError:
# Keepalive — prevents proxies/firewalls from killing idle connections
await ws.send_text('{"kind":"ping"}')
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug("WS client error: %s", e)
finally:
await event_bus.unsubscribe(q)
logger.info("WS client disconnected (%d remaining)", len(event_bus._listeners))