diff --git a/static/js/src/app.ts b/static/js/src/app.ts index 5d29b63..7ad1b1a 100644 --- a/static/js/src/app.ts +++ b/static/js/src/app.ts @@ -15,6 +15,7 @@ import { BeforeInstallPromptEvent } from './types'; import { TranslationCache } from './translationCache'; +import { RequestQueueManager } from './requestQueue'; document.addEventListener('DOMContentLoaded', function() { // Register service worker @@ -102,6 +103,9 @@ function initApp(): void { // Check for saved translations in IndexedDB loadSavedTranslations(); + + // Initialize queue status updates + initQueueStatus(); // Update TTS server URL and API key updateTtsServer.addEventListener('click', function() { @@ -344,7 +348,7 @@ function initApp(): void { } // Function to transcribe audio - function transcribeAudio(audioBlob: Blob): void { + async function transcribeAudio(audioBlob: Blob): Promise { const formData = new FormData(); formData.append('audio', audioBlob, 'audio.webm'); // Add filename for better server handling formData.append('source_lang', sourceLanguage.value); @@ -355,12 +359,26 @@ function initApp(): void { showProgress(); - fetch('/transcribe', { - method: 'POST', - body: formData - }) - .then(response => response.json() as Promise) - .then(data => { + try { + // Use request queue for throttling + const queue = RequestQueueManager.getInstance(); + const data = await queue.enqueue( + 'transcribe', + async () => { + const response = await fetch('/transcribe', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + 8 // Higher priority for transcription + ); + hideProgress(); if (data.success && data.text) { @@ -386,13 +404,18 @@ function initApp(): void { statusIndicator.classList.add('error'); setTimeout(() => statusIndicator.classList.remove('error'), 2000); } - }) - .catch(error => { + } catch (error: any) { hideProgress(); console.error('Transcription error:', error); - sourceText.innerHTML = `

Failed to transcribe audio. Please try again.

`; - statusIndicator.textContent = 'Transcription failed'; - }); + + if (error.message?.includes('Rate limit')) { + sourceText.innerHTML = `

Too many requests. Please wait a moment.

`; + statusIndicator.textContent = 'Rate limit - please wait'; + } else { + sourceText.innerHTML = `

Failed to transcribe audio. Please try again.

`; + statusIndicator.textContent = 'Transcription failed'; + } + } } // Translate button click event @@ -440,15 +463,29 @@ function initApp(): void { target_lang: targetLanguage.value }; - fetch('/translate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(requestBody) - }) - .then(response => response.json() as Promise) - .then(async data => { + try { + // Use request queue for throttling + const queue = RequestQueueManager.getInstance(); + const data = await queue.enqueue( + 'translate', + async () => { + const response = await fetch('/translate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + 5 // Normal priority for translation + ); + hideProgress(); if (data.success && data.translation) { @@ -482,21 +519,21 @@ function initApp(): void { translatedText.innerHTML = `

Error: ${data.error}

`; statusIndicator.textContent = 'Translation failed'; } - }) - .catch(async error => { + } catch (error: any) { hideProgress(); console.error('Translation error:', error); - // Check if we're offline and try to find a similar cached translation - if (!navigator.onLine) { + if (error.message?.includes('Rate limit')) { + translatedText.innerHTML = `

Too many requests. Please wait a moment.

`; + statusIndicator.textContent = 'Rate limit - please wait'; + } else if (!navigator.onLine) { statusIndicator.textContent = 'Offline - checking cache...'; - // Could implement fuzzy matching here for similar translations translatedText.innerHTML = `

You're offline. Only cached translations are available.

`; } else { translatedText.innerHTML = `

Failed to translate. Please try again.

`; + statusIndicator.textContent = 'Translation failed'; } - statusIndicator.textContent = 'Translation failed'; - }); + } }); // Play source text @@ -516,7 +553,7 @@ function initApp(): void { }); // Function to play audio via TTS - function playAudio(text: string, language: string): void { + async function playAudio(text: string, language: string): Promise { showProgress(); showLoadingOverlay('Generating audio...'); @@ -525,15 +562,29 @@ function initApp(): void { language: language }; - fetch('/speak', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(requestBody) - }) - .then(response => response.json() as Promise) - .then(data => { + try { + // Use request queue for throttling + const queue = RequestQueueManager.getInstance(); + const data = await queue.enqueue( + 'tts', + async () => { + const response = await fetch('/speak', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + 3 // Lower priority for TTS + ); + hideProgress(); if (data.success && data.audio_url) { @@ -564,18 +615,23 @@ function initApp(): void { // Check TTS server status again checkTtsServer(); } - }) - .catch(error => { + } catch (error: any) { hideProgress(); console.error('TTS error:', error); - statusIndicator.textContent = 'TTS failed'; - // Show TTS server alert - ttsServerAlert.classList.remove('d-none'); - ttsServerAlert.classList.remove('alert-success'); - ttsServerAlert.classList.add('alert-warning'); - ttsServerMessage.textContent = 'Failed to connect to TTS server'; - }); + if (error.message?.includes('Rate limit')) { + statusIndicator.textContent = 'Too many requests - please wait'; + alert('Too many requests. Please wait a moment before trying again.'); + } else { + statusIndicator.textContent = 'TTS failed'; + + // Show TTS server alert + ttsServerAlert.classList.remove('d-none'); + ttsServerAlert.classList.remove('alert-success'); + ttsServerAlert.classList.add('alert-warning'); + ttsServerMessage.textContent = 'Failed to connect to TTS server'; + } + } } // Clear buttons @@ -669,6 +725,34 @@ function initApp(): void { function hideLoadingOverlay(): void { loadingOverlay.classList.remove('active'); } + + // Initialize queue status display + function initQueueStatus(): void { + const queueStatus = document.getElementById('queueStatus') as HTMLDivElement; + const queueLength = document.getElementById('queueLength') as HTMLSpanElement; + const activeRequests = document.getElementById('activeRequests') as HTMLSpanElement; + + const queue = RequestQueueManager.getInstance(); + + // Update queue status display + function updateQueueDisplay(): void { + const status = queue.getStatus(); + + if (status.queueLength > 0 || status.activeRequests > 0) { + queueStatus.style.display = 'block'; + queueLength.textContent = status.queueLength.toString(); + activeRequests.textContent = status.activeRequests.toString(); + } else { + queueStatus.style.display = 'none'; + } + } + + // Poll for status updates + setInterval(updateQueueDisplay, 500); + + // Initial update + updateQueueDisplay(); + } } diff --git a/static/js/src/requestQueue.ts b/static/js/src/requestQueue.ts new file mode 100644 index 0000000..0ddcc5c --- /dev/null +++ b/static/js/src/requestQueue.ts @@ -0,0 +1,236 @@ +// Request queue and throttling manager +export interface QueuedRequest { + id: string; + type: 'transcribe' | 'translate' | 'tts'; + request: () => Promise; + resolve: (value: any) => void; + reject: (reason?: any) => void; + retryCount: number; + priority: number; + timestamp: number; +} + +export class RequestQueueManager { + private static instance: RequestQueueManager; + private queue: QueuedRequest[] = []; + private activeRequests: Map = new Map(); + private maxConcurrent = 2; // Maximum concurrent requests + private maxRetries = 3; + private retryDelay = 1000; // Base retry delay in ms + private isProcessing = false; + + // Rate limiting + private requestHistory: number[] = []; + private maxRequestsPerMinute = 30; + private maxRequestsPerSecond = 2; + + private constructor() { + // Start processing queue + this.startProcessing(); + } + + static getInstance(): RequestQueueManager { + if (!RequestQueueManager.instance) { + RequestQueueManager.instance = new RequestQueueManager(); + } + return RequestQueueManager.instance; + } + + // Add request to queue + async enqueue( + type: 'transcribe' | 'translate' | 'tts', + request: () => Promise, + priority: number = 5 + ): Promise { + // Check rate limits + if (!this.checkRateLimits()) { + throw new Error('Rate limit exceeded. Please slow down.'); + } + + return new Promise((resolve, reject) => { + const id = this.generateId(); + const queuedRequest: QueuedRequest = { + id, + type, + request, + resolve, + reject, + retryCount: 0, + priority, + timestamp: Date.now() + }; + + // Add to queue based on priority + this.addToQueue(queuedRequest); + + // Log queue status + console.log(`Request queued: ${type}, Queue size: ${this.queue.length}, Active: ${this.activeRequests.size}`); + }); + } + + private addToQueue(request: QueuedRequest): void { + // Insert based on priority (higher priority first) + const insertIndex = this.queue.findIndex(item => item.priority < request.priority); + if (insertIndex === -1) { + this.queue.push(request); + } else { + this.queue.splice(insertIndex, 0, request); + } + } + + private checkRateLimits(): boolean { + const now = Date.now(); + + // Clean old entries + this.requestHistory = this.requestHistory.filter( + time => now - time < 60000 // Keep last minute + ); + + // Check per-second limit + const lastSecond = this.requestHistory.filter( + time => now - time < 1000 + ).length; + + if (lastSecond >= this.maxRequestsPerSecond) { + console.warn('Per-second rate limit reached'); + return false; + } + + // Check per-minute limit + if (this.requestHistory.length >= this.maxRequestsPerMinute) { + console.warn('Per-minute rate limit reached'); + return false; + } + + // Record this request + this.requestHistory.push(now); + return true; + } + + private async startProcessing(): Promise { + if (this.isProcessing) return; + this.isProcessing = true; + + while (true) { + await this.processQueue(); + await this.delay(100); // Check queue every 100ms + } + } + + private async processQueue(): Promise { + // Check if we can process more requests + if (this.activeRequests.size >= this.maxConcurrent || this.queue.length === 0) { + return; + } + + // Get next request + const request = this.queue.shift(); + if (!request) return; + + // Mark as active + this.activeRequests.set(request.id, request); + + try { + // Execute request + const result = await request.request(); + request.resolve(result); + console.log(`Request completed: ${request.type}`); + } catch (error) { + console.error(`Request failed: ${request.type}`, error); + + // Check if we should retry + if (request.retryCount < this.maxRetries && this.shouldRetry(error)) { + request.retryCount++; + const delay = this.calculateRetryDelay(request.retryCount); + console.log(`Retrying request ${request.type} in ${delay}ms (attempt ${request.retryCount})`); + + // Re-queue with delay + setTimeout(() => { + this.addToQueue(request); + }, delay); + } else { + // Max retries reached or non-retryable error + request.reject(error); + } + } finally { + // Remove from active + this.activeRequests.delete(request.id); + } + } + + private shouldRetry(error: any): boolean { + // Retry on network errors or 5xx status codes + if (error.message?.includes('network') || error.message?.includes('Network')) { + return true; + } + + if (error.status >= 500 && error.status < 600) { + return true; + } + + // Don't retry on client errors (4xx) + if (error.status >= 400 && error.status < 500) { + return false; + } + + return true; + } + + private calculateRetryDelay(retryCount: number): number { + // Exponential backoff with jitter + const baseDelay = this.retryDelay * Math.pow(2, retryCount - 1); + const jitter = Math.random() * 0.3 * baseDelay; // 30% jitter + return Math.min(baseDelay + jitter, 30000); // Max 30 seconds + } + + private generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // Get queue status + getStatus(): { + queueLength: number; + activeRequests: number; + requestsPerMinute: number; + } { + const now = Date.now(); + const recentRequests = this.requestHistory.filter( + time => now - time < 60000 + ).length; + + return { + queueLength: this.queue.length, + activeRequests: this.activeRequests.size, + requestsPerMinute: recentRequests + }; + } + + // Clear queue (for emergency use) + clearQueue(): void { + this.queue.forEach(request => { + request.reject(new Error('Queue cleared')); + }); + this.queue = []; + } + + // Update settings + updateSettings(settings: { + maxConcurrent?: number; + maxRequestsPerMinute?: number; + maxRequestsPerSecond?: number; + }): void { + if (settings.maxConcurrent !== undefined) { + this.maxConcurrent = settings.maxConcurrent; + } + if (settings.maxRequestsPerMinute !== undefined) { + this.maxRequestsPerMinute = settings.maxRequestsPerMinute; + } + if (settings.maxRequestsPerSecond !== undefined) { + this.maxRequestsPerSecond = settings.maxRequestsPerSecond; + } + } +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 182d3cd..c7391df 100644 --- a/templates/index.html +++ b/templates/index.html @@ -184,6 +184,13 @@

Click to start recording

+ +