## 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)
53 lines
1.8 KiB
Python
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))
|