Backend: - tiers.py: real license validation via Heimdall (self-hosted key or cloud user_id), 30-min cache, BYOK exception for translation - session_store.py: create_session() accepts tier/user_id/window_ms/transcribe_lang/ num_speakers; tier-aware _should_reap() (Free: reap on subscriber leave, Paid: activity TTL); auto-snapshot paid sessions with history on reap; voice-ready probe on session start - services/translator.py: DeepL-backed label translation, CF-managed and BYOK key paths, per-session label cache - models/session.py, scene_event.py, service_event.py: new event types and session fields - api/snapshots.py: GET /sessions/history + POST /session/resume (Paid tier only) - api/dev.py: dev-only /dev/voice/stop|start|restart proxy to cf-orch agent - migrations/002_session_snapshots.sql: session_snapshots table Frontend: - ComposeBar.vue: pre-session settings panel (window, language, speakers, Elcor toggle) with slide transition; passes options to startSession() - stores/settings.ts: persistent user preferences (localStorage), survives page reload - stores/session.ts: voiceReady/voiceError/voiceWarning states; SceneEvent/AccentEvent/ ServiceEvent types; TTL timers for environ/queue badges; startSession() accepts opts - composables/useToneStream.ts: handlers for scene-event, accent-event, service-event SSE - NowPanel.vue: diarization speaker identity badge, acoustic scene badge, privacy risk indicator - HistoryStrip.vue: per-speaker colour chips in history strip - components/DevPanel.vue: dev-only cf-voice stop/restart controls + thread view toggle - App.vue: DevPanel in footer (DEV only), threadView flag switches v0.1.x/v0.2.x preview Tests: - tests/test_pinning.py: 20 tests covering tier-aware reaping, snapshot creation, and translator behaviour - tests/test_tiers.py: 17 tests covering key validation, cloud resolution, caching, require_paid() gate, BYOK exception - All 72 tests passing
82 lines
3.2 KiB
Python
82 lines
3.2 KiB
Python
# app/api/events.py — SSE stream endpoint
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from dataclasses import replace
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from app.models.service_event import ServiceEvent
|
|
from app.models.tone_event import ToneEvent
|
|
from app.services import session_store
|
|
from app.services.translator import Translator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/session", tags=["events"])
|
|
|
|
|
|
@router.get("/{session_id}/stream")
|
|
async def stream_events(session_id: str, request: Request) -> StreamingResponse:
|
|
"""
|
|
SSE stream of ToneEvent annotations for a session.
|
|
|
|
Clients connect with EventSource:
|
|
const es = new EventSource(`/session/${sessionId}/stream`)
|
|
es.addEventListener('tone-event', e => { ... })
|
|
|
|
If the session has a target_lang set, ToneEvent labels are translated via
|
|
DeepL before emission. Labels are cached per-session — each unique label
|
|
is translated once regardless of how often it appears.
|
|
|
|
The stream runs until the client disconnects or the session ends.
|
|
"""
|
|
session = session_store.get_session(session_id)
|
|
if session is None:
|
|
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
|
|
|
queue = session.subscribe()
|
|
translator = Translator.for_session(session)
|
|
|
|
async def generator():
|
|
try:
|
|
# Heartbeat comment every 15s to keep connection alive through proxies
|
|
yield ": heartbeat\n\n"
|
|
|
|
# If the voice probe already completed before this subscriber connected,
|
|
# emit the result immediately rather than waiting for a queue event
|
|
# that was already broadcast to an empty subscriber list.
|
|
if session.voice_ready:
|
|
yield ServiceEvent(session_id=session_id, status="ready").to_sse()
|
|
elif session.voice_error:
|
|
yield ServiceEvent(
|
|
session_id=session_id, status="error", detail=session.voice_error,
|
|
).to_sse()
|
|
while True:
|
|
if await request.is_disconnected():
|
|
break
|
|
try:
|
|
event = await asyncio.wait_for(queue.get(), timeout=15.0)
|
|
# Translate tone label if a translator is configured for this session
|
|
if translator is not None and isinstance(event, ToneEvent):
|
|
translated = translator.translate(event.label)
|
|
if translated != event.label:
|
|
event = replace(event, label=translated)
|
|
yield event.to_sse()
|
|
except asyncio.TimeoutError:
|
|
yield ": heartbeat\n\n"
|
|
except asyncio.CancelledError:
|
|
break
|
|
finally:
|
|
session.unsubscribe(queue)
|
|
logger.debug("SSE subscriber disconnected from session %s", session_id)
|
|
|
|
return StreamingResponse(
|
|
generator(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no", # disable nginx buffering
|
|
},
|
|
)
|