fix: live flow real-time updates during processing (#861)

* fix: live flow real-time updates during processing

Three gaps caused transcript pages to require manual refresh after
live recording/processing:

1. UserEventsProvider only invalidated list queries on TRANSCRIPT_STATUS,
   not individual transcript queries. Now parses data.id from the event
   and calls invalidateTranscript for the specific transcript.

2. useWebSockets had no reconnection logic — a dropped WS silently
   killed all real-time updates. Added exponential backoff reconnection
   (1s-30s, max 10 retries) with intentional close detection.

3. No polling fallback — WS was single point of failure. Added
   conditional refetchInterval to useTranscriptGet that polls every 5s
   when transcript status is processing/uploaded/recording.

* feat: type-safe WebSocket events via OpenAPI stub

Define Pydantic models with Literal discriminators for all WS events
(9 transcript-level, 5 user-level). Expose via stub GET endpoints so
pnpm openapi generates TS discriminated unions with exhaustive switch
narrowing on the frontend.

- New server/reflector/ws_events.py with TranscriptWsEvent and UserWsEvent
- Tighten backend emit signatures with TranscriptEventName literal
- Frontend uses generated types, removes Zod schema and manual casts
- Fix pre-existing bugs: waveform mapping, FINAL_LONG_SUMMARY field name
- STATUS value now typed as TranscriptStatus literal end-to-end
- TOPIC handler simplified to query invalidation only (avoids shape mismatch)

* fix: restore TOPIC WS handler with immediate state update

The setTopics call provides instant topic rendering during live
transcription. Query invalidation still follows for full data sync.

* fix: align TOPIC WS event data with GetTranscriptTopic shape

Convert TranscriptTopic → GetTranscriptTopic in pipeline before
emitting, so WS sends segments instead of words. Removes the
`as unknown as Topic` cast on the frontend.

* fix: use NonEmptyString and TranscriptStatus in user WS event models

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
This commit is contained in:
2026-02-12 14:49:57 -05:00
committed by GitHub
parent b468427f1b
commit 972a52d22f
13 changed files with 704 additions and 159 deletions

View File

@@ -12,3 +12,5 @@ AccessTokenInfo = auth_module.AccessTokenInfo
authenticated = auth_module.authenticated authenticated = auth_module.authenticated
current_user = auth_module.current_user current_user = auth_module.current_user
current_user_optional = auth_module.current_user_optional current_user_optional = auth_module.current_user_optional
parse_ws_bearer_token = auth_module.parse_ws_bearer_token
current_user_ws_optional = auth_module.current_user_ws_optional

View File

@@ -1,6 +1,9 @@
from typing import Annotated, List, Optional from typing import TYPE_CHECKING, Annotated, List, Optional
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
if TYPE_CHECKING:
from fastapi import WebSocket
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from pydantic import BaseModel from pydantic import BaseModel
@@ -124,3 +127,20 @@ async def current_user_optional(
jwtauth: JWTAuth = Depends(), jwtauth: JWTAuth = Depends(),
): ):
return await _authenticate_user(jwt_token, api_key, jwtauth) return await _authenticate_user(jwt_token, api_key, jwtauth)
def parse_ws_bearer_token(
websocket: "WebSocket",
) -> tuple[Optional[str], Optional[str]]:
raw = websocket.headers.get("sec-websocket-protocol") or ""
parts = [p.strip() for p in raw.split(",") if p.strip()]
if len(parts) >= 2 and parts[0].lower() == "bearer":
return parts[1], "bearer"
return None, None
async def current_user_ws_optional(websocket: "WebSocket") -> Optional[UserInfo]:
token, _ = parse_ws_bearer_token(websocket)
if not token:
return None
return await _authenticate_user(token, None, JWTAuth())

View File

@@ -19,3 +19,11 @@ def current_user():
def current_user_optional(): def current_user_optional():
return None return None
def parse_ws_bearer_token(websocket):
return None, None
async def current_user_ws_optional(websocket):
return None

View File

@@ -5,7 +5,10 @@ import shutil
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Literal, Sequence from typing import TYPE_CHECKING, Any, Literal, Sequence
if TYPE_CHECKING:
from reflector.ws_events import TranscriptEventName
import sqlalchemy import sqlalchemy
from fastapi import HTTPException from fastapi import HTTPException
@@ -184,7 +187,7 @@ class TranscriptWaveform(BaseModel):
class TranscriptEvent(BaseModel): class TranscriptEvent(BaseModel):
event: str event: str # Typed at call sites via ws_events.TranscriptEventName; str here for DB compat
data: dict data: dict
@@ -233,7 +236,9 @@ class Transcript(BaseModel):
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat() return dt.isoformat()
def add_event(self, event: str, data: BaseModel) -> TranscriptEvent: def add_event(
self, event: "TranscriptEventName", data: BaseModel
) -> TranscriptEvent:
ev = TranscriptEvent(event=event, data=data.model_dump()) ev = TranscriptEvent(event=event, data=data.model_dump())
self.events.append(ev) self.events.append(ev)
return ev return ev
@@ -688,7 +693,7 @@ class TranscriptController:
async def append_event( async def append_event(
self, self,
transcript: Transcript, transcript: Transcript,
event: str, event: "TranscriptEventName",
data: Any, data: Any,
) -> TranscriptEvent: ) -> TranscriptEvent:
""" """

View File

@@ -12,10 +12,11 @@ import structlog
from reflector.db.transcripts import Transcript, TranscriptEvent, transcripts_controller from reflector.db.transcripts import Transcript, TranscriptEvent, transcripts_controller
from reflector.utils.string import NonEmptyString from reflector.utils.string import NonEmptyString
from reflector.ws_events import TranscriptEventName
from reflector.ws_manager import get_ws_manager from reflector.ws_manager import get_ws_manager
# Events that should also be sent to user room (matches Celery behavior) # Events that should also be sent to user room (matches Celery behavior)
USER_ROOM_EVENTS = {"STATUS", "FINAL_TITLE", "DURATION"} USER_ROOM_EVENTS: set[TranscriptEventName] = {"STATUS", "FINAL_TITLE", "DURATION"}
async def broadcast_event( async def broadcast_event(
@@ -81,8 +82,7 @@ async def set_status_and_broadcast(
async def append_event_and_broadcast( async def append_event_and_broadcast(
transcript_id: NonEmptyString, transcript_id: NonEmptyString,
transcript: Transcript, transcript: Transcript,
event_name: NonEmptyString, event_name: TranscriptEventName,
# TODO proper dictionary event => type
data: Any, data: Any,
logger: structlog.BoundLogger, logger: structlog.BoundLogger,
) -> TranscriptEvent: ) -> TranscriptEvent:

View File

@@ -62,6 +62,8 @@ from reflector.processors.types import (
from reflector.processors.types import Transcript as TranscriptProcessorType from reflector.processors.types import Transcript as TranscriptProcessorType
from reflector.settings import settings from reflector.settings import settings
from reflector.storage import get_transcripts_storage from reflector.storage import get_transcripts_storage
from reflector.views.transcripts import GetTranscriptTopic
from reflector.ws_events import TranscriptEventName
from reflector.ws_manager import WebsocketManager, get_ws_manager from reflector.ws_manager import WebsocketManager, get_ws_manager
from reflector.zulip import ( from reflector.zulip import (
get_zulip_message, get_zulip_message,
@@ -89,7 +91,11 @@ def broadcast_to_sockets(func):
if transcript and transcript.user_id: if transcript and transcript.user_id:
# Emit only relevant events to the user room to avoid noisy updates. # Emit only relevant events to the user room to avoid noisy updates.
# Allowed: STATUS, FINAL_TITLE, DURATION. All are prefixed with TRANSCRIPT_ # Allowed: STATUS, FINAL_TITLE, DURATION. All are prefixed with TRANSCRIPT_
allowed_user_events = {"STATUS", "FINAL_TITLE", "DURATION"} allowed_user_events: set[TranscriptEventName] = {
"STATUS",
"FINAL_TITLE",
"DURATION",
}
if resp.event in allowed_user_events: if resp.event in allowed_user_events:
await self.ws_manager.send_json( await self.ws_manager.send_json(
room_id=f"user:{transcript.user_id}", room_id=f"user:{transcript.user_id}",
@@ -244,13 +250,14 @@ class PipelineMainBase(PipelineRunner[PipelineMessage], Generic[PipelineMessage]
) )
if isinstance(data, TitleSummaryWithIdProcessorType): if isinstance(data, TitleSummaryWithIdProcessorType):
topic.id = data.id topic.id = data.id
get_topic = GetTranscriptTopic.from_transcript_topic(topic)
async with self.transaction(): async with self.transaction():
transcript = await self.get_transcript() transcript = await self.get_transcript()
await transcripts_controller.upsert_topic(transcript, topic) await transcripts_controller.upsert_topic(transcript, topic)
return await transcripts_controller.append_event( return await transcripts_controller.append_event(
transcript=transcript, transcript=transcript,
event="TOPIC", event="TOPIC",
data=topic, data=get_topic,
) )
@broadcast_to_sockets @broadcast_to_sockets

View File

@@ -4,18 +4,22 @@ Transcripts websocket API
""" """
from typing import Optional from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
import reflector.auth as auth import reflector.auth as auth
from reflector.db.transcripts import transcripts_controller from reflector.db.transcripts import transcripts_controller
from reflector.ws_events import TranscriptWsEvent
from reflector.ws_manager import get_ws_manager from reflector.ws_manager import get_ws_manager
router = APIRouter() router = APIRouter()
@router.get("/transcripts/{transcript_id}/events") @router.get(
"/transcripts/{transcript_id}/events",
response_model=TranscriptWsEvent,
summary="Transcript WebSocket event schema",
description="Stub exposing the discriminated union of all transcript-level WS events for OpenAPI type generation. Real events are delivered over the WebSocket at the same path.",
)
async def transcript_get_websocket_events(transcript_id: str): async def transcript_get_websocket_events(transcript_id: str):
pass pass
@@ -24,8 +28,9 @@ async def transcript_get_websocket_events(transcript_id: str):
async def transcript_events_websocket( async def transcript_events_websocket(
transcript_id: str, transcript_id: str,
websocket: WebSocket, websocket: WebSocket,
user: Optional[auth.UserInfo] = Depends(auth.current_user_optional),
): ):
_, negotiated_subprotocol = auth.parse_ws_bearer_token(websocket)
user = await auth.current_user_ws_optional(websocket)
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
@@ -37,7 +42,9 @@ async def transcript_events_websocket(
# use ts:transcript_id as room id # use ts:transcript_id as room id
room_id = f"ts:{transcript_id}" room_id = f"ts:{transcript_id}"
ws_manager = get_ws_manager() ws_manager = get_ws_manager()
await ws_manager.add_user_to_room(room_id, websocket) await ws_manager.add_user_to_room(
room_id, websocket, subprotocol=negotiated_subprotocol
)
try: try:
# on first connection, send all events only to the current user # on first connection, send all events only to the current user

View File

@@ -4,10 +4,22 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from reflector.auth.auth_jwt import JWTAuth # type: ignore from reflector.auth.auth_jwt import JWTAuth # type: ignore
from reflector.db.users import user_controller from reflector.db.users import user_controller
from reflector.ws_events import UserWsEvent
from reflector.ws_manager import get_ws_manager from reflector.ws_manager import get_ws_manager
router = APIRouter() router = APIRouter()
@router.get(
"/events",
response_model=UserWsEvent,
summary="User WebSocket event schema",
description="Stub exposing the discriminated union of all user-level WS events for OpenAPI type generation. Real events are delivered over the WebSocket at the same path.",
)
async def user_get_websocket_events():
pass
# Close code for unauthorized WebSocket connections # Close code for unauthorized WebSocket connections
UNAUTHORISED = 4401 UNAUTHORISED = 4401

View File

@@ -0,0 +1,188 @@
"""Typed WebSocket event models.
Defines Pydantic models with Literal discriminators for all WS events.
Exposed via stub GET endpoints so ``pnpm openapi`` generates TS discriminated unions.
"""
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Discriminator
from reflector.db.transcripts import (
TranscriptActionItems,
TranscriptDuration,
TranscriptFinalLongSummary,
TranscriptFinalShortSummary,
TranscriptFinalTitle,
TranscriptStatus,
TranscriptText,
TranscriptWaveform,
)
from reflector.utils.string import NonEmptyString
from reflector.views.transcripts import GetTranscriptTopic
# ---------------------------------------------------------------------------
# Transcript-level event name literal
# ---------------------------------------------------------------------------
TranscriptEventName = Literal[
"TRANSCRIPT",
"TOPIC",
"STATUS",
"FINAL_TITLE",
"FINAL_LONG_SUMMARY",
"FINAL_SHORT_SUMMARY",
"ACTION_ITEMS",
"DURATION",
"WAVEFORM",
]
# ---------------------------------------------------------------------------
# Transcript-level WS event wrappers
# ---------------------------------------------------------------------------
class TranscriptWsTranscript(BaseModel):
event: Literal["TRANSCRIPT"] = "TRANSCRIPT"
data: TranscriptText
class TranscriptWsTopic(BaseModel):
event: Literal["TOPIC"] = "TOPIC"
data: GetTranscriptTopic
class TranscriptWsStatusData(BaseModel):
value: TranscriptStatus
class TranscriptWsStatus(BaseModel):
event: Literal["STATUS"] = "STATUS"
data: TranscriptWsStatusData
class TranscriptWsFinalTitle(BaseModel):
event: Literal["FINAL_TITLE"] = "FINAL_TITLE"
data: TranscriptFinalTitle
class TranscriptWsFinalLongSummary(BaseModel):
event: Literal["FINAL_LONG_SUMMARY"] = "FINAL_LONG_SUMMARY"
data: TranscriptFinalLongSummary
class TranscriptWsFinalShortSummary(BaseModel):
event: Literal["FINAL_SHORT_SUMMARY"] = "FINAL_SHORT_SUMMARY"
data: TranscriptFinalShortSummary
class TranscriptWsActionItems(BaseModel):
event: Literal["ACTION_ITEMS"] = "ACTION_ITEMS"
data: TranscriptActionItems
class TranscriptWsDuration(BaseModel):
event: Literal["DURATION"] = "DURATION"
data: TranscriptDuration
class TranscriptWsWaveform(BaseModel):
event: Literal["WAVEFORM"] = "WAVEFORM"
data: TranscriptWaveform
TranscriptWsEvent = Annotated[
Union[
TranscriptWsTranscript,
TranscriptWsTopic,
TranscriptWsStatus,
TranscriptWsFinalTitle,
TranscriptWsFinalLongSummary,
TranscriptWsFinalShortSummary,
TranscriptWsActionItems,
TranscriptWsDuration,
TranscriptWsWaveform,
],
Discriminator("event"),
]
# ---------------------------------------------------------------------------
# User-level event name literal
# ---------------------------------------------------------------------------
UserEventName = Literal[
"TRANSCRIPT_CREATED",
"TRANSCRIPT_DELETED",
"TRANSCRIPT_STATUS",
"TRANSCRIPT_FINAL_TITLE",
"TRANSCRIPT_DURATION",
]
# ---------------------------------------------------------------------------
# User-level WS event data models
# ---------------------------------------------------------------------------
class UserTranscriptCreatedData(BaseModel):
id: NonEmptyString
class UserTranscriptDeletedData(BaseModel):
id: NonEmptyString
class UserTranscriptStatusData(BaseModel):
id: NonEmptyString
value: TranscriptStatus
class UserTranscriptFinalTitleData(BaseModel):
id: NonEmptyString
title: NonEmptyString
class UserTranscriptDurationData(BaseModel):
id: NonEmptyString
duration: float
# ---------------------------------------------------------------------------
# User-level WS event wrappers
# ---------------------------------------------------------------------------
class UserWsTranscriptCreated(BaseModel):
event: Literal["TRANSCRIPT_CREATED"] = "TRANSCRIPT_CREATED"
data: UserTranscriptCreatedData
class UserWsTranscriptDeleted(BaseModel):
event: Literal["TRANSCRIPT_DELETED"] = "TRANSCRIPT_DELETED"
data: UserTranscriptDeletedData
class UserWsTranscriptStatus(BaseModel):
event: Literal["TRANSCRIPT_STATUS"] = "TRANSCRIPT_STATUS"
data: UserTranscriptStatusData
class UserWsTranscriptFinalTitle(BaseModel):
event: Literal["TRANSCRIPT_FINAL_TITLE"] = "TRANSCRIPT_FINAL_TITLE"
data: UserTranscriptFinalTitleData
class UserWsTranscriptDuration(BaseModel):
event: Literal["TRANSCRIPT_DURATION"] = "TRANSCRIPT_DURATION"
data: UserTranscriptDurationData
UserWsEvent = Annotated[
Union[
UserWsTranscriptCreated,
UserWsTranscriptDeleted,
UserWsTranscriptStatus,
UserWsTranscriptFinalTitle,
UserWsTranscriptDuration,
],
Discriminator("event"),
]

View File

@@ -1,18 +1,22 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Topic, FinalSummary, Status } from "./webSocketTypes"; import { Topic, FinalSummary, Status } from "./webSocketTypes";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import type { components } from "../../reflector-api"; import type { components, operations } from "../../reflector-api";
type AudioWaveform = components["schemas"]["AudioWaveform"]; type AudioWaveform = components["schemas"]["AudioWaveform"];
type GetTranscriptSegmentTopic = type GetTranscriptSegmentTopic =
components["schemas"]["GetTranscriptSegmentTopic"]; components["schemas"]["GetTranscriptSegmentTopic"];
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { $api, WEBSOCKET_URL } from "../../lib/apiClient"; import { WEBSOCKET_URL } from "../../lib/apiClient";
import { import {
invalidateTranscript, invalidateTranscript,
invalidateTranscriptTopics, invalidateTranscriptTopics,
invalidateTranscriptWaveform, invalidateTranscriptWaveform,
} from "../../lib/apiHooks"; } from "../../lib/apiHooks";
import { NonEmptyString } from "../../lib/utils"; import { useAuth } from "../../lib/AuthProvider";
import { parseNonEmptyString } from "../../lib/utils";
type TranscriptWsEvent =
operations["v1_transcript_get_websocket_events"]["responses"][200]["content"]["application/json"];
export type UseWebSockets = { export type UseWebSockets = {
transcriptTextLive: string; transcriptTextLive: string;
@@ -27,6 +31,7 @@ export type UseWebSockets = {
}; };
export const useWebSockets = (transcriptId: string | null): UseWebSockets => { export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const auth = useAuth();
const [transcriptTextLive, setTranscriptTextLive] = useState<string>(""); const [transcriptTextLive, setTranscriptTextLive] = useState<string>("");
const [translateText, setTranslateText] = useState<string>(""); const [translateText, setTranslateText] = useState<string>("");
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
@@ -331,156 +336,168 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
}; };
if (!transcriptId) return; if (!transcriptId) return;
const tsId = parseNonEmptyString(transcriptId);
const MAX_RETRIES = 10;
const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`; const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
let ws = new WebSocket(url); let ws: WebSocket | null = null;
let retryCount = 0;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
let intentionalClose = false;
ws.onopen = () => { const connect = () => {
console.debug("WebSocket connection opened"); const subprotocols = auth.accessToken
}; ? ["bearer", auth.accessToken]
: undefined;
ws = new WebSocket(url, subprotocols);
ws.onmessage = (event) => { ws.onopen = () => {
const message = JSON.parse(event.data); console.debug("WebSocket connection opened");
retryCount = 0;
};
try { ws.onmessage = (event) => {
switch (message.event) { const message: TranscriptWsEvent = JSON.parse(event.data);
case "TRANSCRIPT":
const newText = (message.data.text ?? "").trim();
const newTranslation = (message.data.translation ?? "").trim();
if (!newText) break; try {
switch (message.event) {
case "TRANSCRIPT": {
const newText = (message.data.text ?? "").trim();
const newTranslation = (message.data.translation ?? "").trim();
console.debug("TRANSCRIPT event:", newText); if (!newText) break;
setTextQueue((prevQueue) => [...prevQueue, newText]);
setTranslationQueue((prevQueue) => [...prevQueue, newTranslation]);
setAccumulatedText((prevText) => prevText + " " + newText); console.debug("TRANSCRIPT event:", newText);
break; setTextQueue((prevQueue) => [...prevQueue, newText]);
setTranslationQueue((prevQueue) => [
...prevQueue,
newTranslation,
]);
case "TOPIC": setAccumulatedText((prevText) => prevText + " " + newText);
setTopics((prevTopics) => { break;
const topic = message.data as Topic;
const index = prevTopics.findIndex(
(prevTopic) => prevTopic.id === topic.id,
);
if (index >= 0) {
prevTopics[index] = topic;
return prevTopics;
}
setAccumulatedText((prevText) =>
prevText.slice(topic.transcript.length),
);
return [...prevTopics, topic];
});
console.debug("TOPIC event:", message.data);
// Invalidate topics query to sync with WebSocket data
invalidateTranscriptTopics(
queryClient,
transcriptId as NonEmptyString,
);
break;
case "FINAL_SHORT_SUMMARY":
console.debug("FINAL_SHORT_SUMMARY event:", message.data);
break;
case "FINAL_LONG_SUMMARY":
if (message.data) {
setFinalSummary(message.data);
// Invalidate transcript query to sync summary
invalidateTranscript(queryClient, transcriptId as NonEmptyString);
} }
break;
case "FINAL_TITLE": case "TOPIC":
console.debug("FINAL_TITLE event:", message.data); setTopics((prevTopics) => {
if (message.data) { const topic = message.data;
const index = prevTopics.findIndex(
(prevTopic) => prevTopic.id === topic.id,
);
if (index >= 0) {
prevTopics[index] = topic;
return prevTopics;
}
setAccumulatedText((prevText) =>
prevText.slice(topic.transcript?.length ?? 0),
);
return [...prevTopics, topic];
});
console.debug("TOPIC event:", message.data);
invalidateTranscriptTopics(queryClient, tsId);
break;
case "FINAL_SHORT_SUMMARY":
console.debug("FINAL_SHORT_SUMMARY event:", message.data);
break;
case "FINAL_LONG_SUMMARY":
setFinalSummary({ summary: message.data.long_summary });
invalidateTranscript(queryClient, tsId);
break;
case "FINAL_TITLE":
console.debug("FINAL_TITLE event:", message.data);
setTitle(message.data.title); setTitle(message.data.title);
// Invalidate transcript query to sync title invalidateTranscript(queryClient, tsId);
invalidateTranscript(queryClient, transcriptId as NonEmptyString); break;
}
break;
case "WAVEFORM": case "WAVEFORM":
console.debug( console.debug(
"WAVEFORM event length:", "WAVEFORM event length:",
message.data.waveform.length, message.data.waveform.length,
);
if (message.data) {
setWaveForm(message.data.waveform);
invalidateTranscriptWaveform(
queryClient,
transcriptId as NonEmptyString,
); );
} setWaveForm({ data: message.data.waveform });
break; invalidateTranscriptWaveform(queryClient, tsId);
case "DURATION": break;
console.debug("DURATION event:", message.data);
if (message.data) { case "DURATION":
console.debug("DURATION event:", message.data);
setDuration(message.data.duration); setDuration(message.data.duration);
} break;
break;
case "STATUS": case "STATUS":
console.log("STATUS event:", message.data); console.log("STATUS event:", message.data);
if (message.data.value === "error") { if (message.data.value === "error") {
setError( setError(
Error("Websocket error status"), Error("Websocket error status"),
"There was an error processing this meeting.", "There was an error processing this meeting.",
);
}
setStatus(message.data);
invalidateTranscript(queryClient, tsId);
if (message.data.value === "ended") {
intentionalClose = true;
ws?.close();
}
break;
case "ACTION_ITEMS":
console.debug("ACTION_ITEMS event:", message.data);
invalidateTranscript(queryClient, tsId);
break;
default: {
const _exhaustive: never = message;
console.warn(
`Received unknown WebSocket event: ${(_exhaustive as TranscriptWsEvent).event}`,
); );
} }
setStatus(message.data); }
invalidateTranscript(queryClient, transcriptId as NonEmptyString); } catch (error) {
if (message.data.value === "ended") { setError(error);
ws.close();
}
break;
default:
setError(
new Error(`Received unknown WebSocket event: ${message.event}`),
);
} }
} catch (error) { };
setError(error);
}
};
ws.onerror = (error) => { ws.onerror = (error) => {
console.error("WebSocket error:", error); console.error("WebSocket error:", error);
setError(new Error("A WebSocket error occurred.")); };
};
ws.onclose = (event) => { ws.onclose = (event) => {
console.debug("WebSocket connection closed"); console.debug("WebSocket connection closed, code:", event.code);
switch (event.code) { if (intentionalClose) return;
case 1000: // Normal Closure:
break; const normalCodes = [1000, 1001, 1005];
case 1005: // Closure by client FF if (normalCodes.includes(event.code)) return;
break;
case 1001: // Navigate away if (retryCount < MAX_RETRIES) {
break; const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
case 1006: // Closed by client Chrome console.log(
console.warn( `WebSocket reconnecting in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`,
"WebSocket closed by client, likely duplicated connection in react dev mode",
); );
break; if (retryCount === 0) {
default: setError(
new Error("WebSocket connection lost"),
"Connection lost. Reconnecting...",
);
}
retryCount++;
retryTimeout = setTimeout(connect, delay);
} else {
setError( setError(
new Error(`WebSocket closed unexpectedly with code: ${event.code}`), new Error(`WebSocket closed unexpectedly with code: ${event.code}`),
"Disconnected from the server. Please refresh the page.", "Disconnected from the server. Please refresh the page.",
); );
console.log( }
"Socket is closed. Reconnect will be attempted in 1 second.", };
event.reason,
);
// todo handle reconnect with socket.io
}
}; };
connect();
return () => { return () => {
ws.close(); intentionalClose = true;
if (retryTimeout) clearTimeout(retryTimeout);
ws?.close();
}; };
}, [transcriptId]); }, [transcriptId]);

View File

@@ -4,14 +4,12 @@ import React, { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { WEBSOCKET_URL } from "./apiClient"; import { WEBSOCKET_URL } from "./apiClient";
import { useAuth } from "./AuthProvider"; import { useAuth } from "./AuthProvider";
import { z } from "zod"; import { invalidateTranscript, invalidateTranscriptLists } from "./apiHooks";
import { invalidateTranscriptLists, TRANSCRIPT_SEARCH_URL } from "./apiHooks"; import { parseNonEmptyString } from "./utils";
import type { operations } from "../reflector-api";
const UserEvent = z.object({ type UserWsEvent =
event: z.string(), operations["v1_user_get_websocket_events"]["responses"][200]["content"]["application/json"];
});
type UserEvent = z.TypeOf<typeof UserEvent>;
class UserEventsStore { class UserEventsStore {
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
@@ -133,23 +131,26 @@ export function UserEventsProvider({
if (!detachRef.current) { if (!detachRef.current) {
const onMessage = (event: MessageEvent) => { const onMessage = (event: MessageEvent) => {
try { try {
const msg = UserEvent.parse(JSON.parse(event.data)); const msg: UserWsEvent = JSON.parse(event.data);
const eventName = msg.event;
const invalidateList = () => invalidateTranscriptLists(queryClient); switch (msg.event) {
switch (eventName) {
case "TRANSCRIPT_CREATED": case "TRANSCRIPT_CREATED":
case "TRANSCRIPT_DELETED": case "TRANSCRIPT_DELETED":
case "TRANSCRIPT_STATUS": case "TRANSCRIPT_STATUS":
case "TRANSCRIPT_FINAL_TITLE": case "TRANSCRIPT_FINAL_TITLE":
case "TRANSCRIPT_DURATION": case "TRANSCRIPT_DURATION":
invalidateList().then(() => {}); invalidateTranscriptLists(queryClient).then(() => {});
break; invalidateTranscript(
queryClient,
default: parseNonEmptyString(msg.data.id),
// Ignore other content events for list updates ).then(() => {});
break; break;
default: {
const _exhaustive: never = msg;
console.warn(
`Unknown user event: ${(_exhaustive as UserWsEvent).event}`,
);
}
} }
} catch (err) { } catch (err) {
console.warn("Invalid user event message", event.data); console.warn("Invalid user event message", event.data);

View File

@@ -7,6 +7,7 @@ import type { components } from "../reflector-api";
import { useAuth } from "./AuthProvider"; import { useAuth } from "./AuthProvider";
import { MeetingId } from "./types"; import { MeetingId } from "./types";
import { NonEmptyString } from "./utils"; import { NonEmptyString } from "./utils";
import type { TranscriptStatus } from "./transcript";
/* /*
* XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other * XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other
@@ -104,6 +105,12 @@ export function useTranscriptProcess() {
}); });
} }
const ACTIVE_TRANSCRIPT_STATUSES = new Set<TranscriptStatus>([
"processing",
"uploaded",
"recording",
]);
export function useTranscriptGet(transcriptId: NonEmptyString | null) { export function useTranscriptGet(transcriptId: NonEmptyString | null) {
return $api.useQuery( return $api.useQuery(
"get", "get",
@@ -117,6 +124,10 @@ export function useTranscriptGet(transcriptId: NonEmptyString | null) {
}, },
{ {
enabled: !!transcriptId, enabled: !!transcriptId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status && ACTIVE_TRANSCRIPT_STATUSES.has(status) ? 5000 : false;
},
}, },
); );
} }

View File

@@ -568,7 +568,10 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Transcript Get Websocket Events */ /**
* Transcript WebSocket event schema
* @description Stub exposing the discriminated union of all transcript-level WS events for OpenAPI type generation. Real events are delivered over the WebSocket at the same path.
*/
get: operations["v1_transcript_get_websocket_events"]; get: operations["v1_transcript_get_websocket_events"];
put?: never; put?: never;
post?: never; post?: never;
@@ -664,6 +667,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* User WebSocket event schema
* @description Stub exposing the discriminated union of all user-level WS events for OpenAPI type generation. Real events are delivered over the WebSocket at the same path.
*/
get: operations["v1_user_get_websocket_events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/zulip/streams": { "/v1/zulip/streams": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1877,6 +1900,33 @@ export interface components {
/** Name */ /** Name */
name: string; name: string;
}; };
/** TranscriptActionItems */
TranscriptActionItems: {
/** Action Items */
action_items: {
[key: string]: unknown;
};
};
/** TranscriptDuration */
TranscriptDuration: {
/** Duration */
duration: number;
};
/** TranscriptFinalLongSummary */
TranscriptFinalLongSummary: {
/** Long Summary */
long_summary: string;
};
/** TranscriptFinalShortSummary */
TranscriptFinalShortSummary: {
/** Short Summary */
short_summary: string;
};
/** TranscriptFinalTitle */
TranscriptFinalTitle: {
/** Title */
title: string;
};
/** TranscriptParticipant */ /** TranscriptParticipant */
TranscriptParticipant: { TranscriptParticipant: {
/** Id */ /** Id */
@@ -1917,6 +1967,113 @@ export interface components {
/** End */ /** End */
end: number; end: number;
}; };
/** TranscriptText */
TranscriptText: {
/** Text */
text: string;
/** Translation */
translation: string | null;
};
/** TranscriptWaveform */
TranscriptWaveform: {
/** Waveform */
waveform: number[];
};
/** TranscriptWsActionItems */
TranscriptWsActionItems: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "ACTION_ITEMS";
data: components["schemas"]["TranscriptActionItems"];
};
/** TranscriptWsDuration */
TranscriptWsDuration: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "DURATION";
data: components["schemas"]["TranscriptDuration"];
};
/** TranscriptWsFinalLongSummary */
TranscriptWsFinalLongSummary: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "FINAL_LONG_SUMMARY";
data: components["schemas"]["TranscriptFinalLongSummary"];
};
/** TranscriptWsFinalShortSummary */
TranscriptWsFinalShortSummary: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "FINAL_SHORT_SUMMARY";
data: components["schemas"]["TranscriptFinalShortSummary"];
};
/** TranscriptWsFinalTitle */
TranscriptWsFinalTitle: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "FINAL_TITLE";
data: components["schemas"]["TranscriptFinalTitle"];
};
/** TranscriptWsStatus */
TranscriptWsStatus: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "STATUS";
data: components["schemas"]["TranscriptWsStatusData"];
};
/** TranscriptWsStatusData */
TranscriptWsStatusData: {
/**
* Value
* @enum {string}
*/
value:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
};
/** TranscriptWsTopic */
TranscriptWsTopic: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "TOPIC";
data: components["schemas"]["GetTranscriptTopic"];
};
/** TranscriptWsTranscript */
TranscriptWsTranscript: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "TRANSCRIPT";
data: components["schemas"]["TranscriptText"];
};
/** TranscriptWsWaveform */
TranscriptWsWaveform: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "WAVEFORM";
data: components["schemas"]["TranscriptWaveform"];
};
/** UpdateParticipant */ /** UpdateParticipant */
UpdateParticipant: { UpdateParticipant: {
/** Speaker */ /** Speaker */
@@ -1987,6 +2144,82 @@ export interface components {
/** Email */ /** Email */
email: string | null; email: string | null;
}; };
/** UserTranscriptCreatedData */
UserTranscriptCreatedData: {
/** Id */
id: string;
};
/** UserTranscriptDeletedData */
UserTranscriptDeletedData: {
/** Id */
id: string;
};
/** UserTranscriptDurationData */
UserTranscriptDurationData: {
/** Id */
id: string;
/** Duration */
duration: number;
};
/** UserTranscriptFinalTitleData */
UserTranscriptFinalTitleData: {
/** Id */
id: string;
/** Title */
title: string;
};
/** UserTranscriptStatusData */
UserTranscriptStatusData: {
/** Id */
id: string;
/** Value */
value: string;
};
/** UserWsTranscriptCreated */
UserWsTranscriptCreated: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "TRANSCRIPT_CREATED";
data: components["schemas"]["UserTranscriptCreatedData"];
};
/** UserWsTranscriptDeleted */
UserWsTranscriptDeleted: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "TRANSCRIPT_DELETED";
data: components["schemas"]["UserTranscriptDeletedData"];
};
/** UserWsTranscriptDuration */
UserWsTranscriptDuration: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "TRANSCRIPT_DURATION";
data: components["schemas"]["UserTranscriptDurationData"];
};
/** UserWsTranscriptFinalTitle */
UserWsTranscriptFinalTitle: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "TRANSCRIPT_FINAL_TITLE";
data: components["schemas"]["UserTranscriptFinalTitleData"];
};
/** UserWsTranscriptStatus */
UserWsTranscriptStatus: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
event: "TRANSCRIPT_STATUS";
data: components["schemas"]["UserTranscriptStatusData"];
};
/** ValidationError */ /** ValidationError */
ValidationError: { ValidationError: {
/** Location */ /** Location */
@@ -3423,7 +3656,16 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": unknown; "application/json":
| components["schemas"]["TranscriptWsTranscript"]
| components["schemas"]["TranscriptWsTopic"]
| components["schemas"]["TranscriptWsStatus"]
| components["schemas"]["TranscriptWsFinalTitle"]
| components["schemas"]["TranscriptWsFinalLongSummary"]
| components["schemas"]["TranscriptWsFinalShortSummary"]
| components["schemas"]["TranscriptWsActionItems"]
| components["schemas"]["TranscriptWsDuration"]
| components["schemas"]["TranscriptWsWaveform"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@@ -3607,6 +3849,31 @@ export interface operations {
}; };
}; };
}; };
v1_user_get_websocket_events: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json":
| components["schemas"]["UserWsTranscriptCreated"]
| components["schemas"]["UserWsTranscriptDeleted"]
| components["schemas"]["UserWsTranscriptStatus"]
| components["schemas"]["UserWsTranscriptFinalTitle"]
| components["schemas"]["UserWsTranscriptDuration"];
};
};
};
};
v1_zulip_get_streams: { v1_zulip_get_streams: {
parameters: { parameters: {
query?: never; query?: never;