/** * Streaming State Persistence * * Manages browser storage of streaming response state to enable: * - Resume interrupted streams after page refresh * - Graceful recovery from network disconnections */ import type { ExtendedResponseStreamEvent } from "@/types/openai"; export interface StreamingState { conversationId: string; responseId: string; lastMessageId?: string; lastSequenceNumber: number; timestamp: number; // When this state was last updated completed: boolean; // Whether the stream completed successfully accumulatedText?: string; // Bounded tail preview for refresh restoration accumulatedTextIsPreview?: boolean; } const STORAGE_KEY_PREFIX = "devui_streaming_state_"; const STATE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours const MAX_ACCUMULATED_TEXT_PREVIEW_CHARS = 16 * 1024; interface CreateStreamingStateOptions { conversationId: string; responseId: string; lastMessageId?: string; lastSequenceNumber?: number; accumulatedText?: string; accumulatedTextIsPreview?: boolean; } /** * Storage key for a specific conversation */ function getStorageKey(conversationId: string): string { return `${STORAGE_KEY_PREFIX}${conversationId}`; } function normalizeAccumulatedTextPreview(state: StreamingState): StreamingState { if ( state.accumulatedText === undefined || state.accumulatedText.length <= MAX_ACCUMULATED_TEXT_PREVIEW_CHARS ) { return state; } return { ...state, accumulatedText: state.accumulatedText.slice(-MAX_ACCUMULATED_TEXT_PREVIEW_CHARS), accumulatedTextIsPreview: true, }; } /** * Read raw streaming state from storage, including completed entries. */ function readStreamingState(conversationId: string): StreamingState | null { const key = getStorageKey(conversationId); const data = localStorage.getItem(key); if (!data) { return null; } const state: StreamingState = JSON.parse(data); // Check if state has expired const age = Date.now() - state.timestamp; if (age > STATE_EXPIRY_MS) { clearStreamingState(conversationId); return null; } return normalizeAccumulatedTextPreview(state); } /** * Create an initial streaming state snapshot. */ export function createStreamingState({ conversationId, responseId, lastMessageId, lastSequenceNumber = -1, accumulatedText, accumulatedTextIsPreview = false, }: CreateStreamingStateOptions): StreamingState { return normalizeAccumulatedTextPreview({ conversationId, responseId, lastMessageId, lastSequenceNumber, timestamp: Date.now(), completed: false, accumulatedText, accumulatedTextIsPreview, }); } /** * Apply an incoming stream event to an in-memory streaming state snapshot. */ export function applyStreamingEventToState( state: StreamingState, event: ExtendedResponseStreamEvent, responseId: string, lastMessageId?: string ): StreamingState { const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined; const nextState: StreamingState = { ...state, responseId, lastMessageId, timestamp: Date.now(), completed: event.type === "response.completed" || event.type === "response.failed", }; if (sequenceNumber !== undefined) { nextState.lastSequenceNumber = sequenceNumber; } if ( event.type === "response.output_text.delta" && "delta" in event && typeof event.delta === "string" && event.delta.length > 0 ) { const accumulatedText = `${state.accumulatedText ?? ""}${event.delta}`; const isPreview = state.accumulatedTextIsPreview || accumulatedText.length > MAX_ACCUMULATED_TEXT_PREVIEW_CHARS; nextState.accumulatedText = isPreview ? accumulatedText.slice(-MAX_ACCUMULATED_TEXT_PREVIEW_CHARS) : accumulatedText; nextState.accumulatedTextIsPreview = isPreview; } return nextState; } /** * Save streaming state to browser storage */ export function saveStreamingState(state: StreamingState): void { try { const key = getStorageKey(state.conversationId); const data = JSON.stringify(normalizeAccumulatedTextPreview(state)); localStorage.setItem(key, data); } catch (error) { console.error("Failed to save streaming state:", error); // If storage is full, try to clear old states try { clearExpiredStreamingStates(); // Try again const key = getStorageKey(state.conversationId); const data = JSON.stringify(normalizeAccumulatedTextPreview(state)); localStorage.setItem(key, data); } catch { console.error("Failed to save streaming state even after cleanup"); } } } /** * Load streaming state from browser storage */ export function loadStreamingState(conversationId: string): StreamingState | null { try { const state = readStreamingState(conversationId); if (!state) { return null; } // If stream was completed, no need to resume if (state.completed) { return null; } return state; } catch (error) { console.error("Failed to load streaming state:", error); return null; } } /** * Clear streaming state for a conversation */ export function clearStreamingState(conversationId: string): void { try { const key = getStorageKey(conversationId); localStorage.removeItem(key); } catch (error) { console.error("Failed to clear streaming state:", error); } } /** * Clear all expired streaming states */ export function clearExpiredStreamingStates(): void { try { const keys = Object.keys(localStorage); const now = Date.now(); for (const key of keys) { if (key.startsWith(STORAGE_KEY_PREFIX)) { try { const data = localStorage.getItem(key); if (data) { const state: StreamingState = JSON.parse(data); const age = now - state.timestamp; if (age > STATE_EXPIRY_MS || state.completed) { localStorage.removeItem(key); } } } catch { // Invalid state, remove it localStorage.removeItem(key); } } } } catch (error) { console.error("Failed to clear expired streaming states:", error); } } /** * Initialize streaming state management (call on app startup) */ export function initStreamingState(): void { // Clear expired states on startup clearExpiredStreamingStates(); }