// Streaming translation implementation for reduced latency import { Validator } from './validator'; import { PerformanceMonitor } from './performanceMonitor'; export interface StreamChunk { type: 'start' | 'chunk' | 'complete' | 'error'; text?: string; full_text?: string; error?: string; source_lang?: string; target_lang?: string; } export class StreamingTranslation { private eventSource: EventSource | null = null; private abortController: AbortController | null = null; private performanceMonitor = PerformanceMonitor.getInstance(); private firstChunkReceived = false; constructor( private onChunk: (text: string) => void, private onComplete: (fullText: string) => void, private onError: (error: string) => void, private onStart?: () => void ) {} async startStreaming( text: string, sourceLang: string, targetLang: string, useStreaming: boolean = true ): Promise { // Cancel any existing stream this.cancel(); // Validate inputs const sanitizedText = Validator.sanitizeText(text); if (!sanitizedText) { this.onError('No text to translate'); return; } if (!useStreaming) { // Fall back to regular translation await this.fallbackToRegularTranslation(sanitizedText, sourceLang, targetLang); return; } try { // Check if browser supports EventSource if (!window.EventSource) { console.warn('EventSource not supported, falling back to regular translation'); await this.fallbackToRegularTranslation(sanitizedText, sourceLang, targetLang); return; } // Notify start if (this.onStart) { this.onStart(); } // Start performance timing this.performanceMonitor.startTimer('streaming_translation'); this.firstChunkReceived = false; // Create abort controller for cleanup this.abortController = new AbortController(); // Start streaming request const response = await fetch('/translate/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ text: sanitizedText, source_lang: sourceLang, target_lang: targetLang }), signal: this.abortController.signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } // Check if response is event-stream const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('text/event-stream')) { throw new Error('Server does not support streaming'); } // Process the stream await this.processStream(response); } catch (error: any) { if (error.name === 'AbortError') { console.log('Stream cancelled'); return; } console.error('Streaming error:', error); // Fall back to regular translation on error await this.fallbackToRegularTranslation(sanitizedText, sourceLang, targetLang); } } private async processStream(response: Response): Promise { const reader = response.body?.getReader(); if (!reader) { throw new Error('No response body'); } const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { done, value } = await reader.read(); if (done) { break; } buffer += decoder.decode(value, { stream: true }); // Process complete SSE messages const lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)) as StreamChunk; this.handleStreamChunk(data); } catch (e) { console.error('Failed to parse SSE data:', e); } } } } } finally { reader.releaseLock(); } } private handleStreamChunk(chunk: StreamChunk): void { switch (chunk.type) { case 'start': console.log('Translation started:', chunk.source_lang, '->', chunk.target_lang); break; case 'chunk': if (chunk.text) { // Record time to first byte if (!this.firstChunkReceived) { this.firstChunkReceived = true; this.performanceMonitor.measureTTFB('streaming_translation', performance.now()); } this.onChunk(chunk.text); } break; case 'complete': if (chunk.full_text) { // End performance timing this.performanceMonitor.endTimer('streaming_translation'); this.onComplete(chunk.full_text); // Log performance stats periodically if (Math.random() < 0.1) { // 10% of the time this.performanceMonitor.logPerformanceStats(); } } break; case 'error': this.onError(chunk.error || 'Unknown streaming error'); break; } } private async fallbackToRegularTranslation( text: string, sourceLang: string, targetLang: string ): Promise { try { const response = await fetch('/translate', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ text: text, source_lang: sourceLang, target_lang: targetLang }) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); if (data.success && data.translation) { // Simulate streaming by showing text progressively this.simulateStreaming(data.translation); } else { this.onError(data.error || 'Translation failed'); } } catch (error: any) { this.onError(error.message || 'Translation failed'); } } private simulateStreaming(text: string): void { // Simulate streaming for better UX even with non-streaming response const words = text.split(' '); let index = 0; let accumulated = ''; const interval = setInterval(() => { if (index >= words.length) { clearInterval(interval); this.onComplete(accumulated.trim()); return; } const chunk = words[index] + (index < words.length - 1 ? ' ' : ''); accumulated += chunk; this.onChunk(chunk); index++; }, 50); // 50ms between words for smooth appearance } cancel(): void { if (this.abortController) { this.abortController.abort(); this.abortController = null; } if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } } }