talk2me/static/js/app.js
Adolfo Delorenzo 17e0f2f03d Add connection retry logic to handle network interruptions gracefully
- Implement ConnectionManager with exponential backoff retry strategy
- Add automatic connection monitoring and health checks
- Update RequestQueueManager to integrate with connection state
- Create ConnectionUI component for visual connection status
- Queue requests during offline periods and process when online
- Add comprehensive error handling for network-related failures
- Create detailed documentation for connection retry features
- Support manual retry and automatic recovery

Features:
- Real-time connection status indicator
- Offline banner with retry button
- Request queue visualization
- Priority-based request processing
- Configurable retry parameters

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-03 00:00:03 -06:00

1385 lines
64 KiB
JavaScript

import { TranslationCache } from './translationCache';
import { RequestQueueManager } from './requestQueue';
import { ErrorBoundary } from './errorBoundary';
import { Validator } from './validator';
import { StreamingTranslation } from './streamingTranslation';
import { PerformanceMonitor } from './performanceMonitor';
import { SpeakerManager } from './speakerManager';
import { ConnectionManager } from './connectionManager';
import { ConnectionUI } from './connectionUI';
// import { apiClient } from './apiClient'; // Available for cross-origin requests
// Initialize error boundary
const errorBoundary = ErrorBoundary.getInstance();
// Initialize connection management
ConnectionManager.getInstance(); // Initialize connection manager
const connectionUI = ConnectionUI.getInstance();
// Configure API client if needed for cross-origin requests
// import { apiClient } from './apiClient';
// apiClient.configure({ baseUrl: 'https://api.talk2me.com', credentials: 'include' });
document.addEventListener('DOMContentLoaded', function () {
// Set up global error handler
errorBoundary.setGlobalErrorHandler((error, errorInfo) => {
console.error('Global error caught:', error);
// Show user-friendly message based on component
if (errorInfo.component === 'transcription') {
const statusIndicator = document.getElementById('statusIndicator');
if (statusIndicator) {
statusIndicator.textContent = 'Transcription failed. Please try again.';
statusIndicator.classList.add('text-danger');
}
}
else if (errorInfo.component === 'translation') {
const translatedText = document.getElementById('translatedText');
if (translatedText) {
translatedText.innerHTML = '<p class="text-danger">Translation failed. Please try again.</p>';
}
}
});
// Wrap initialization functions with error boundaries
const safeRegisterServiceWorker = errorBoundary.wrapAsync(registerServiceWorker, 'service-worker', async () => console.warn('Service worker registration failed, continuing without PWA features'));
const safeInitApp = errorBoundary.wrap(initApp, 'app-init', () => console.error('App initialization failed'));
const safeInitInstallPrompt = errorBoundary.wrap(initInstallPrompt, 'install-prompt', () => console.warn('Install prompt initialization failed'));
// Register service worker
if ('serviceWorker' in navigator) {
safeRegisterServiceWorker();
}
// Initialize app
safeInitApp();
// Check for PWA installation prompts
safeInitInstallPrompt();
});
// Service Worker Registration
async function registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js');
console.log('Service Worker registered with scope:', registration.scope);
// Setup periodic sync if available
if ('periodicSync' in registration && registration.periodicSync) {
// Request permission for background sync
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
if (status.state === 'granted') {
try {
// Register for background sync to check for updates
await registration.periodicSync.register('translation-updates', {
minInterval: 24 * 60 * 60 * 1000, // once per day
});
console.log('Periodic background sync registered');
}
catch (error) {
console.error('Periodic background sync could not be registered:', error);
}
}
}
// Setup push notification if available
if ('PushManager' in window) {
setupPushNotifications(registration);
}
}
catch (error) {
console.error('Service Worker registration failed:', error);
}
}
// Initialize the main application
function initApp() {
// DOM elements
const recordBtn = document.getElementById('recordBtn');
const translateBtn = document.getElementById('translateBtn');
const sourceText = document.getElementById('sourceText');
const translatedText = document.getElementById('translatedText');
const sourceLanguage = document.getElementById('sourceLanguage');
const targetLanguage = document.getElementById('targetLanguage');
const playSource = document.getElementById('playSource');
const playTranslation = document.getElementById('playTranslation');
const clearSource = document.getElementById('clearSource');
const clearTranslation = document.getElementById('clearTranslation');
const statusIndicator = document.getElementById('statusIndicator');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const audioPlayer = document.getElementById('audioPlayer');
const ttsServerAlert = document.getElementById('ttsServerAlert');
const ttsServerMessage = document.getElementById('ttsServerMessage');
const ttsServerUrl = document.getElementById('ttsServerUrl');
const ttsApiKey = document.getElementById('ttsApiKey');
const updateTtsServer = document.getElementById('updateTtsServer');
const loadingOverlay = document.getElementById('loadingOverlay');
const loadingText = document.getElementById('loadingText');
// Set initial values
let isRecording = false;
let mediaRecorder = null;
let audioChunks = [];
let currentSourceText = '';
let currentTranslationText = '';
let currentTtsServerUrl = '';
// Performance monitoring
const performanceMonitor = PerformanceMonitor.getInstance();
// Speaker management
const speakerManager = SpeakerManager.getInstance();
let multiSpeakerEnabled = false;
// Check TTS server status on page load
checkTtsServer();
// Check for saved translations in IndexedDB
loadSavedTranslations();
// Initialize queue status updates
initQueueStatus();
// Start health monitoring
startHealthMonitoring();
// Initialize multi-speaker mode
initMultiSpeakerMode();
// Multi-speaker mode implementation
function initMultiSpeakerMode() {
const multiSpeakerToggle = document.getElementById('toggleMultiSpeaker');
const multiSpeakerStatus = document.getElementById('multiSpeakerStatus');
const speakerToolbar = document.getElementById('speakerToolbar');
const conversationView = document.getElementById('conversationView');
const multiSpeakerModeCheckbox = document.getElementById('multiSpeakerMode');
// Load saved preference
multiSpeakerEnabled = localStorage.getItem('multiSpeakerMode') === 'true';
if (multiSpeakerModeCheckbox) {
multiSpeakerModeCheckbox.checked = multiSpeakerEnabled;
}
// Show/hide multi-speaker UI based on setting
if (multiSpeakerEnabled) {
speakerToolbar.style.display = 'block';
conversationView.style.display = 'block';
multiSpeakerStatus.textContent = 'ON';
}
// Toggle multi-speaker mode
multiSpeakerToggle?.addEventListener('click', () => {
multiSpeakerEnabled = !multiSpeakerEnabled;
multiSpeakerStatus.textContent = multiSpeakerEnabled ? 'ON' : 'OFF';
if (multiSpeakerEnabled) {
speakerToolbar.style.display = 'block';
conversationView.style.display = 'block';
// Add default speaker if none exist
if (speakerManager.getAllSpeakers().length === 0) {
const defaultSpeaker = speakerManager.addSpeaker('Speaker 1', sourceLanguage.value);
speakerManager.setActiveSpeaker(defaultSpeaker.id);
updateSpeakerUI();
}
}
else {
speakerToolbar.style.display = 'none';
conversationView.style.display = 'none';
}
localStorage.setItem('multiSpeakerMode', multiSpeakerEnabled.toString());
if (multiSpeakerModeCheckbox) {
multiSpeakerModeCheckbox.checked = multiSpeakerEnabled;
}
});
// Add speaker button
document.getElementById('addSpeakerBtn')?.addEventListener('click', () => {
const name = prompt('Enter speaker name:');
if (name) {
const speaker = speakerManager.addSpeaker(name, sourceLanguage.value);
speakerManager.setActiveSpeaker(speaker.id);
updateSpeakerUI();
}
});
// Update speaker UI
function updateSpeakerUI() {
const speakerList = document.getElementById('speakerList');
speakerList.innerHTML = '';
speakerManager.getAllSpeakers().forEach(speaker => {
const btn = document.createElement('button');
btn.className = `speaker-button ${speaker.isActive ? 'active' : ''}`;
btn.style.borderColor = speaker.color;
btn.style.backgroundColor = speaker.isActive ? speaker.color : 'white';
btn.style.color = speaker.isActive ? 'white' : speaker.color;
btn.innerHTML = `
<span class="speaker-avatar">${speaker.avatar}</span>
${speaker.name}
`;
btn.addEventListener('click', () => {
speakerManager.setActiveSpeaker(speaker.id);
updateSpeakerUI();
});
speakerList.appendChild(btn);
});
}
// Export conversation
document.getElementById('exportConversation')?.addEventListener('click', () => {
const text = speakerManager.exportConversation(targetLanguage.value);
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `conversation_${new Date().toISOString()}.txt`;
a.click();
URL.revokeObjectURL(url);
});
// Clear conversation
document.getElementById('clearConversation')?.addEventListener('click', () => {
if (confirm('Clear all conversation history?')) {
speakerManager.clearConversation();
updateConversationView();
}
});
// Update conversation view
function updateConversationView() {
const conversationContent = document.getElementById('conversationContent');
const entries = speakerManager.getConversationInLanguage(targetLanguage.value);
conversationContent.innerHTML = entries.map(entry => `
<div class="conversation-entry">
<div class="conversation-speaker">
<span class="conversation-speaker-avatar" style="background-color: ${entry.speakerColor}">
${entry.speakerName.substr(0, 2).toUpperCase()}
</span>
<span style="color: ${entry.speakerColor}">${entry.speakerName}</span>
<span class="conversation-time">${new Date(entry.timestamp).toLocaleTimeString()}</span>
</div>
<div class="conversation-text ${!entry.isOriginal ? 'conversation-translation' : ''}">
${Validator.sanitizeHTML(entry.text)}
</div>
</div>
`).join('');
// Scroll to bottom
conversationContent.scrollTop = conversationContent.scrollHeight;
}
// Store reference to update function for use in transcription
window.updateConversationView = updateConversationView;
window.updateSpeakerUI = updateSpeakerUI;
}
// Update TTS server URL and API key
updateTtsServer.addEventListener('click', function () {
const newUrl = ttsServerUrl.value.trim();
const newApiKey = ttsApiKey.value.trim();
if (!newUrl && !newApiKey) {
alert('Please provide at least one value to update');
return;
}
const updateData = {};
// Validate URL
if (newUrl) {
const validatedUrl = Validator.validateURL(newUrl);
if (!validatedUrl) {
alert('Invalid server URL. Please enter a valid HTTP/HTTPS URL.');
return;
}
updateData.server_url = validatedUrl;
}
// Validate API key
if (newApiKey) {
const validatedKey = Validator.validateAPIKey(newApiKey);
if (!validatedKey) {
alert('Invalid API key format. API keys should be 20-128 characters and contain only letters, numbers, dashes, and underscores.');
return;
}
updateData.api_key = validatedKey;
}
fetch('/update_tts_config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updateData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusIndicator.textContent = 'TTS configuration updated';
// Save URL to localStorage but not the API key for security
if (newUrl)
localStorage.setItem('ttsServerUrl', newUrl);
// Check TTS server with new configuration
checkTtsServer();
}
else {
alert('Failed to update TTS configuration: ' + data.error);
}
})
.catch(error => {
console.error('Failed to update TTS config:', error);
alert('Failed to update TTS configuration. See console for details.');
});
});
// Make sure target language is different from source
if (targetLanguage.options[0].value === sourceLanguage.value) {
targetLanguage.selectedIndex = 1;
}
// Event listeners for language selection
sourceLanguage.addEventListener('change', function () {
// Skip conflict check for auto-detect
if (sourceLanguage.value === 'auto') {
return;
}
if (targetLanguage.value === sourceLanguage.value) {
for (let i = 0; i < targetLanguage.options.length; i++) {
if (targetLanguage.options[i].value !== sourceLanguage.value) {
targetLanguage.selectedIndex = i;
break;
}
}
}
});
targetLanguage.addEventListener('change', function () {
if (targetLanguage.value === sourceLanguage.value) {
for (let i = 0; i < sourceLanguage.options.length; i++) {
if (sourceLanguage.options[i].value !== targetLanguage.value) {
sourceLanguage.selectedIndex = i;
break;
}
}
}
});
// Record button click event
recordBtn.addEventListener('click', function () {
if (isRecording) {
stopRecording();
}
else {
startRecording();
}
});
// Function to start recording
function startRecording() {
// Request audio with specific constraints for better compression
const audioConstraints = {
audio: {
channelCount: 1, // Mono audio (reduces size by 50%)
sampleRate: 16000, // Lower sample rate for speech (16kHz is enough for speech)
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
};
navigator.mediaDevices.getUserMedia(audioConstraints)
.then(stream => {
// Use webm/opus for better compression (if supported)
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
const options = {
mimeType: mimeType,
audioBitsPerSecond: 32000 // Low bitrate for speech (32 kbps)
};
try {
mediaRecorder = new MediaRecorder(stream, options);
}
catch (e) {
// Fallback to default if options not supported
console.warn('Compression options not supported, using defaults');
mediaRecorder = new MediaRecorder(stream);
}
audioChunks = [];
mediaRecorder.addEventListener('dataavailable', event => {
audioChunks.push(event.data);
});
mediaRecorder.addEventListener('stop', async () => {
// Create blob with appropriate MIME type
const mimeType = mediaRecorder?.mimeType || 'audio/webm';
const audioBlob = new Blob(audioChunks, { type: mimeType });
// Log compression results
const sizeInKB = (audioBlob.size / 1024).toFixed(2);
console.log(`Audio compressed to ${sizeInKB} KB (${mimeType})`);
// If the audio is still too large, we can compress it further
if (audioBlob.size > 500 * 1024) { // If larger than 500KB
statusIndicator.textContent = 'Compressing audio...';
const compressedBlob = await compressAudioBlob(audioBlob);
transcribeAudio(compressedBlob);
}
else {
transcribeAudio(audioBlob);
}
});
mediaRecorder.start();
isRecording = true;
recordBtn.classList.add('recording');
recordBtn.classList.replace('btn-primary', 'btn-danger');
recordBtn.innerHTML = '<div class="recording-wave"><span></span><span></span><span></span><span></span><span></span></div>';
statusIndicator.textContent = 'Recording... Click to stop';
statusIndicator.classList.add('processing');
})
.catch(error => {
console.error('Error accessing microphone:', error);
alert('Error accessing microphone. Please make sure you have given permission for microphone access.');
});
}
// Function to stop recording
function stopRecording() {
if (!mediaRecorder)
return;
mediaRecorder.stop();
isRecording = false;
recordBtn.classList.remove('recording');
recordBtn.classList.replace('btn-danger', 'btn-primary');
recordBtn.innerHTML = '<i class="fas fa-microphone"></i>';
statusIndicator.textContent = 'Processing audio...';
statusIndicator.classList.add('processing');
showLoadingOverlay('Transcribing your speech...');
// Stop all audio tracks
mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
// Function to compress audio blob if needed
async function compressAudioBlob(blob) {
return new Promise((resolve) => {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const reader = new FileReader();
reader.onload = async (e) => {
try {
const arrayBuffer = e.target?.result;
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// Downsample to 16kHz mono
const offlineContext = new OfflineAudioContext(1, audioBuffer.duration * 16000, 16000);
const source = offlineContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(offlineContext.destination);
source.start();
const compressedBuffer = await offlineContext.startRendering();
// Convert to WAV format
const wavBlob = audioBufferToWav(compressedBuffer);
const compressedSizeKB = (wavBlob.size / 1024).toFixed(2);
console.log(`Further compressed to ${compressedSizeKB} KB`);
resolve(wavBlob);
}
catch (error) {
console.error('Compression failed, using original:', error);
resolve(blob); // Return original if compression fails
}
};
reader.readAsArrayBuffer(blob);
});
}
// Convert AudioBuffer to WAV format
function audioBufferToWav(buffer) {
const length = buffer.length * buffer.numberOfChannels * 2;
const arrayBuffer = new ArrayBuffer(44 + length);
const view = new DataView(arrayBuffer);
// WAV header
const writeString = (offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + length, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, buffer.numberOfChannels, true);
view.setUint32(24, buffer.sampleRate, true);
view.setUint32(28, buffer.sampleRate * buffer.numberOfChannels * 2, true);
view.setUint16(32, buffer.numberOfChannels * 2, true);
view.setUint16(34, 16, true);
writeString(36, 'data');
view.setUint32(40, length, true);
// Convert float samples to 16-bit PCM
let offset = 44;
for (let i = 0; i < buffer.length; i++) {
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i]));
view.setInt16(offset, sample * 0x7FFF, true);
offset += 2;
}
}
return new Blob([arrayBuffer], { type: 'audio/wav' });
}
// Function to transcribe audio
const transcribeAudioBase = async function (audioBlob) {
// Validate audio file
const validation = Validator.validateAudioFile(new File([audioBlob], 'audio.webm', { type: audioBlob.type }));
if (!validation.valid) {
statusIndicator.textContent = validation.error || 'Invalid audio file';
statusIndicator.classList.add('text-danger');
hideProgress();
hideLoadingOverlay();
return;
}
// Validate language code
const validatedLang = Validator.validateLanguageCode(sourceLanguage.value, Array.from(sourceLanguage.options).map(opt => opt.value));
if (!validatedLang && sourceLanguage.value !== 'auto') {
statusIndicator.textContent = 'Invalid source language selected';
statusIndicator.classList.add('text-danger');
hideProgress();
hideLoadingOverlay();
return;
}
const formData = new FormData();
formData.append('audio', audioBlob, Validator.sanitizeFilename('audio.webm'));
formData.append('source_lang', validatedLang || 'auto');
// Log upload size
const sizeInKB = (audioBlob.size / 1024).toFixed(2);
console.log(`Uploading ${sizeInKB} KB of audio data`);
showProgress();
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) {
// Sanitize the transcribed text
const sanitizedText = Validator.sanitizeText(data.text);
currentSourceText = sanitizedText;
// Handle multi-speaker mode
if (multiSpeakerEnabled) {
const activeSpeaker = speakerManager.getActiveSpeaker();
if (activeSpeaker) {
const entry = speakerManager.addConversationEntry(activeSpeaker.id, sanitizedText, data.detected_language || sourceLanguage.value);
// Auto-translate for all other speakers' languages
const allLanguages = new Set(speakerManager.getAllSpeakers().map(s => s.language));
allLanguages.delete(entry.originalLanguage);
allLanguages.forEach(async (lang) => {
try {
const response = await fetch('/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: sanitizedText,
source_lang: entry.originalLanguage,
target_lang: lang
})
});
if (response.ok) {
const result = await response.json();
if (result.success && result.translation) {
speakerManager.addTranslation(entry.id, lang, result.translation);
if (window.updateConversationView) {
window.updateConversationView();
}
}
}
}
catch (error) {
console.error(`Failed to translate to ${lang}:`, error);
}
});
// Update conversation view
if (window.updateConversationView) {
window.updateConversationView();
}
}
}
// Handle auto-detected language
if (data.detected_language && sourceLanguage.value === 'auto') {
// Update the source language selector
sourceLanguage.value = data.detected_language;
// Show detected language info with sanitized HTML
sourceText.innerHTML = `<p class="fade-in">${Validator.sanitizeHTML(sanitizedText)}</p>
<small class="text-muted">Detected language: ${Validator.sanitizeHTML(data.detected_language)}</small>`;
statusIndicator.textContent = `Transcription complete (${data.detected_language} detected)`;
}
else {
sourceText.innerHTML = `<p class="fade-in">${Validator.sanitizeHTML(sanitizedText)}</p>`;
statusIndicator.textContent = 'Transcription complete';
}
playSource.disabled = false;
translateBtn.disabled = false;
statusIndicator.classList.remove('processing');
statusIndicator.classList.add('success');
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
// Cache the transcription in IndexedDB
saveToIndexedDB('transcriptions', {
text: data.text,
language: data.detected_language || sourceLanguage.value,
timestamp: new Date().toISOString()
});
}
else {
sourceText.innerHTML = `<p class="text-danger fade-in">Error: ${data.error}</p>`;
statusIndicator.textContent = 'Transcription failed';
statusIndicator.classList.remove('processing');
statusIndicator.classList.add('error');
setTimeout(() => statusIndicator.classList.remove('error'), 2000);
}
}
catch (error) {
hideProgress();
console.error('Transcription error:', error);
if (error.message?.includes('Rate limit')) {
sourceText.innerHTML = `<p class="text-warning">Too many requests. Please wait a moment.</p>`;
statusIndicator.textContent = 'Rate limit - please wait';
}
else if (error.message?.includes('connection') || error.message?.includes('network')) {
sourceText.innerHTML = `<p class="text-warning">Connection error. Your request will be processed when connection is restored.</p>`;
statusIndicator.textContent = 'Connection error - queued';
connectionUI.showTemporaryMessage('Request queued for when connection returns', 'warning');
}
else if (!navigator.onLine) {
sourceText.innerHTML = `<p class="text-warning">You're offline. Request will be sent when connection is restored.</p>`;
statusIndicator.textContent = 'Offline - request queued';
}
else {
sourceText.innerHTML = `<p class="text-danger">Failed to transcribe audio. Please try again.</p>`;
statusIndicator.textContent = 'Transcription failed';
}
}
};
// Wrap transcribe function with error boundary
const transcribeAudio = errorBoundary.wrapAsync(transcribeAudioBase, 'transcription', async () => {
hideProgress();
hideLoadingOverlay();
sourceText.innerHTML = '<p class="text-danger">Transcription failed. Please try again.</p>';
statusIndicator.textContent = 'Transcription error - please retry';
statusIndicator.classList.remove('processing');
statusIndicator.classList.add('error');
});
// Translate button click event
translateBtn.addEventListener('click', errorBoundary.wrapAsync(async function () {
if (!currentSourceText) {
return;
}
// Check if streaming is enabled
const streamingEnabled = localStorage.getItem('streamingTranslation') !== 'false';
// Check if offline mode is enabled
const offlineModeEnabled = localStorage.getItem('offlineMode') !== 'false';
if (offlineModeEnabled) {
statusIndicator.textContent = 'Checking cache...';
statusIndicator.classList.add('processing');
// Check cache first
const cachedTranslation = await TranslationCache.getCachedTranslation(currentSourceText, sourceLanguage.value, targetLanguage.value);
if (cachedTranslation) {
// Use cached translation
console.log('Using cached translation');
currentTranslationText = cachedTranslation;
translatedText.innerHTML = `<p class="fade-in">${cachedTranslation} <small class="text-muted">(cached)</small></p>`;
playTranslation.disabled = false;
statusIndicator.textContent = 'Translation complete (from cache)';
statusIndicator.classList.remove('processing');
statusIndicator.classList.add('success');
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
return;
}
}
// No cache hit, proceed with API call
statusIndicator.textContent = 'Translating...';
// Use streaming if enabled
if (streamingEnabled && navigator.onLine) {
// Clear previous translation
translatedText.innerHTML = '<p class="fade-in streaming-text"></p>';
const streamingTextElement = translatedText.querySelector('.streaming-text');
let accumulatedText = '';
// Show minimal loading indicator for streaming
statusIndicator.classList.add('processing');
const streamingTranslation = new StreamingTranslation(
// onChunk - append text as it arrives
(chunk) => {
accumulatedText += chunk;
streamingTextElement.textContent = accumulatedText;
streamingTextElement.classList.add('streaming-active');
},
// onComplete - finalize the translation
async (fullText) => {
const sanitizedTranslation = Validator.sanitizeText(fullText);
currentTranslationText = sanitizedTranslation;
streamingTextElement.textContent = sanitizedTranslation;
streamingTextElement.classList.remove('streaming-active');
playTranslation.disabled = false;
statusIndicator.textContent = 'Translation complete';
statusIndicator.classList.remove('processing');
statusIndicator.classList.add('success');
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
// Cache the translation
if (offlineModeEnabled) {
await TranslationCache.cacheTranslation(currentSourceText, sourceLanguage.value, sanitizedTranslation, targetLanguage.value);
}
// Save to history
saveToIndexedDB('translations', {
sourceText: currentSourceText,
sourceLanguage: sourceLanguage.value,
targetText: sanitizedTranslation,
targetLanguage: targetLanguage.value,
timestamp: new Date().toISOString()
});
},
// onError - handle streaming errors
(error) => {
translatedText.innerHTML = `<p class="text-danger">Error: ${Validator.sanitizeHTML(error)}</p>`;
statusIndicator.textContent = 'Translation failed';
statusIndicator.classList.remove('processing');
statusIndicator.classList.add('error');
},
// onStart
() => {
console.log('Starting streaming translation');
});
try {
await streamingTranslation.startStreaming(currentSourceText, sourceLanguage.value, targetLanguage.value, true // use streaming
);
}
catch (error) {
console.error('Streaming translation failed:', error);
// Fall back to regular translation is handled internally
}
return; // Exit early for streaming
}
// Regular non-streaming translation
showProgress();
showLoadingOverlay('Translating to ' + targetLanguage.value + '...');
// Validate input text size
if (!Validator.validateRequestSize({ text: currentSourceText }, 100)) {
translatedText.innerHTML = '<p class="text-danger">Text is too long to translate. Please shorten it.</p>';
statusIndicator.textContent = 'Text too long';
hideProgress();
hideLoadingOverlay();
return;
}
// Validate language codes
const validatedSourceLang = Validator.validateLanguageCode(sourceLanguage.value, Array.from(sourceLanguage.options).map(opt => opt.value));
const validatedTargetLang = Validator.validateLanguageCode(targetLanguage.value, Array.from(targetLanguage.options).map(opt => opt.value));
if (!validatedTargetLang) {
translatedText.innerHTML = '<p class="text-danger">Invalid target language selected</p>';
statusIndicator.textContent = 'Invalid language';
hideProgress();
hideLoadingOverlay();
return;
}
const requestBody = {
text: Validator.sanitizeText(currentSourceText),
source_lang: validatedSourceLang || 'auto',
target_lang: validatedTargetLang
};
try {
// Start performance timing for regular translation
performanceMonitor.startTimer('regular_translation');
// 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) {
// End performance timing
performanceMonitor.endTimer('regular_translation');
// Sanitize the translated text
const sanitizedTranslation = Validator.sanitizeText(data.translation);
currentTranslationText = sanitizedTranslation;
translatedText.innerHTML = `<p class="fade-in">${Validator.sanitizeHTML(sanitizedTranslation)}</p>`;
playTranslation.disabled = false;
statusIndicator.textContent = 'Translation complete';
statusIndicator.classList.remove('processing');
statusIndicator.classList.add('success');
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
// Cache the translation for offline use if enabled
if (offlineModeEnabled) {
await TranslationCache.cacheTranslation(currentSourceText, sourceLanguage.value, data.translation, targetLanguage.value);
}
// Also save to regular history
saveToIndexedDB('translations', {
sourceText: currentSourceText,
sourceLanguage: sourceLanguage.value,
targetText: data.translation,
targetLanguage: targetLanguage.value,
timestamp: new Date().toISOString()
});
}
else {
translatedText.innerHTML = `<p class="text-danger">Error: ${data.error}</p>`;
statusIndicator.textContent = 'Translation failed';
}
}
catch (error) {
hideProgress();
console.error('Translation error:', error);
if (error.message?.includes('Rate limit')) {
translatedText.innerHTML = `<p class="text-warning">Too many requests. Please wait a moment.</p>`;
statusIndicator.textContent = 'Rate limit - please wait';
}
else if (error.message?.includes('connection') || error.message?.includes('network')) {
translatedText.innerHTML = `<p class="text-warning">Connection error. Your translation will be processed when connection is restored.</p>`;
statusIndicator.textContent = 'Connection error - queued';
connectionUI.showTemporaryMessage('Translation queued for when connection returns', 'warning');
}
else if (!navigator.onLine) {
statusIndicator.textContent = 'Offline - checking cache...';
translatedText.innerHTML = `<p class="text-warning">You're offline. Only cached translations are available.</p>`;
}
else {
translatedText.innerHTML = `<p class="text-danger">Failed to translate. Please try again.</p>`;
statusIndicator.textContent = 'Translation failed';
}
}
}, 'translation', async () => {
hideProgress();
hideLoadingOverlay();
translatedText.innerHTML = '<p class="text-danger">Translation failed. Please try again.</p>';
statusIndicator.textContent = 'Translation error - please retry';
}));
// Play source text
playSource.addEventListener('click', function () {
if (!currentSourceText)
return;
playAudio(currentSourceText, sourceLanguage.value);
statusIndicator.textContent = 'Playing source audio...';
});
// Play translation
playTranslation.addEventListener('click', function () {
if (!currentTranslationText)
return;
playAudio(currentTranslationText, targetLanguage.value);
statusIndicator.textContent = 'Playing translation audio...';
});
// Function to play audio via TTS
const playAudioBase = async function (text, language) {
showProgress();
showLoadingOverlay('Generating audio...');
const requestBody = {
text: text,
language: language
};
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) {
audioPlayer.src = data.audio_url;
audioPlayer.onloadeddata = function () {
hideLoadingOverlay();
// Show audio playing animation
const playingAnimation = '<div class="audio-playing"><span></span><span></span><span></span><span></span><span></span></div>';
statusIndicator.innerHTML = playingAnimation + ' Playing audio...';
};
audioPlayer.onended = function () {
statusIndicator.innerHTML = '';
statusIndicator.textContent = 'Ready';
statusIndicator.classList.remove('processing');
};
audioPlayer.play();
}
else {
statusIndicator.textContent = 'TTS failed';
// Show TTS server alert with error message
ttsServerAlert.classList.remove('d-none');
ttsServerAlert.classList.remove('alert-success');
ttsServerAlert.classList.add('alert-warning');
ttsServerMessage.textContent = data.error || 'TTS failed';
alert('Failed to play audio: ' + data.error);
// Check TTS server status again
checkTtsServer();
}
}
catch (error) {
hideProgress();
console.error('TTS error:', error);
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 if (error.message?.includes('connection') || error.message?.includes('network')) {
statusIndicator.textContent = 'Connection error - audio generation queued';
connectionUI.showTemporaryMessage('Audio generation queued for when connection returns', 'warning');
// Show TTS server alert
ttsServerAlert.classList.remove('d-none');
ttsServerAlert.classList.remove('alert-success');
ttsServerAlert.classList.add('alert-warning');
ttsServerMessage.textContent = 'Connection error - request queued';
}
else if (!navigator.onLine) {
statusIndicator.textContent = 'Offline - audio generation unavailable';
alert('Audio generation requires an internet connection.');
}
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';
}
}
};
// Wrap playAudio function with error boundary
const playAudio = errorBoundary.wrapAsync(playAudioBase, 'tts', async () => {
hideProgress();
hideLoadingOverlay();
statusIndicator.textContent = 'Audio playback failed';
alert('Failed to generate audio. Please check your TTS server connection.');
});
// Clear buttons
clearSource.addEventListener('click', function () {
sourceText.innerHTML = '<p class="text-muted">Your transcribed text will appear here...</p>';
currentSourceText = '';
playSource.disabled = true;
translateBtn.disabled = true;
});
clearTranslation.addEventListener('click', function () {
translatedText.innerHTML = '<p class="text-muted">Translation will appear here...</p>';
currentTranslationText = '';
playTranslation.disabled = true;
});
// Function to check TTS server status
function checkTtsServer() {
fetch('/check_tts_server')
.then(response => response.json())
.then(data => {
currentTtsServerUrl = data.url;
ttsServerUrl.value = currentTtsServerUrl;
// Load saved API key if available
const savedApiKey = localStorage.getItem('ttsApiKeySet');
if (savedApiKey === 'true') {
ttsApiKey.placeholder = '••••••• (API key saved)';
}
if (data.status === 'error' || data.status === 'auth_error') {
ttsServerAlert.classList.remove('d-none');
ttsServerAlert.classList.remove('alert-success');
ttsServerAlert.classList.add('alert-warning');
ttsServerMessage.textContent = data.message;
if (data.status === 'auth_error') {
ttsServerMessage.textContent = 'Authentication error with TTS server. Please check your API key.';
}
}
else {
ttsServerAlert.classList.remove('d-none');
ttsServerAlert.classList.remove('alert-warning');
ttsServerAlert.classList.add('alert-success');
ttsServerMessage.textContent = 'TTS server is online and ready.';
setTimeout(() => {
ttsServerAlert.classList.add('d-none');
}, 3000);
}
})
.catch(error => {
console.error('Failed to check TTS server:', error);
ttsServerAlert.classList.remove('d-none');
ttsServerAlert.classList.remove('alert-success');
ttsServerAlert.classList.add('alert-warning');
ttsServerMessage.textContent = 'Failed to check TTS server status.';
});
}
// Progress indicator functions
function showProgress() {
progressContainer.classList.remove('d-none');
let progress = 0;
const interval = setInterval(() => {
progress += 5;
if (progress > 90) {
clearInterval(interval);
}
progressBar.style.width = `${progress}%`;
}, 100);
progressBar.dataset.interval = interval.toString();
}
function hideProgress() {
const interval = progressBar.dataset.interval;
if (interval) {
clearInterval(Number(interval));
}
progressBar.style.width = '100%';
setTimeout(() => {
progressContainer.classList.add('d-none');
progressBar.style.width = '0%';
}, 500);
hideLoadingOverlay();
}
function showLoadingOverlay(text) {
loadingText.textContent = text;
loadingOverlay.classList.add('active');
}
function hideLoadingOverlay() {
loadingOverlay.classList.remove('active');
}
// Initialize queue status display
function initQueueStatus() {
const queueStatus = document.getElementById('queueStatus');
const queueLength = document.getElementById('queueLength');
const activeRequests = document.getElementById('activeRequests');
const queue = RequestQueueManager.getInstance();
// Update queue status display
function updateQueueDisplay() {
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();
}
// Health monitoring and auto-recovery
function startHealthMonitoring() {
let consecutiveFailures = 0;
const maxConsecutiveFailures = 3;
async function checkHealth() {
try {
const response = await fetch('/health', {
method: 'GET',
signal: AbortSignal.timeout(5000) // 5 second timeout
});
if (response.ok) {
consecutiveFailures = 0;
// Remove any health warning if shown
const healthWarning = document.getElementById('healthWarning');
if (healthWarning) {
healthWarning.style.display = 'none';
}
}
else {
handleHealthCheckFailure();
}
}
catch (error) {
handleHealthCheckFailure();
}
}
function handleHealthCheckFailure() {
consecutiveFailures++;
console.warn(`Health check failed (${consecutiveFailures}/${maxConsecutiveFailures})`);
if (consecutiveFailures >= maxConsecutiveFailures) {
showHealthWarning();
// Attempt auto-recovery
attemptAutoRecovery();
}
}
function showHealthWarning() {
let healthWarning = document.getElementById('healthWarning');
if (!healthWarning) {
healthWarning = document.createElement('div');
healthWarning.id = 'healthWarning';
healthWarning.className = 'alert alert-warning alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3';
healthWarning.style.zIndex = '9999';
healthWarning.innerHTML = `
<i class="fas fa-exclamation-triangle"></i> Service health check failed.
Some features may be unavailable.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(healthWarning);
}
healthWarning.style.display = 'block';
}
async function attemptAutoRecovery() {
console.log('Attempting auto-recovery...');
// Clear any stuck requests in the queue
const queue = RequestQueueManager.getInstance();
queue.clearStuckRequests();
// Re-check TTS server
checkTtsServer();
// Try to reload service worker if available
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
console.log('Service worker updated');
}
}
catch (error) {
console.error('Failed to update service worker:', error);
}
}
// Reset failure counter after recovery attempt
setTimeout(() => {
consecutiveFailures = 0;
}, 30000); // Wait 30 seconds before resetting
}
// Check health every 30 seconds
setInterval(checkHealth, 30000);
// Initial health check after 5 seconds
setTimeout(checkHealth, 5000);
}
}
// IndexedDB functions for offline data storage
function openIndexedDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('VoiceTranslatorDB', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create stores for transcriptions and translations
if (!db.objectStoreNames.contains('transcriptions')) {
db.createObjectStore('transcriptions', { keyPath: 'timestamp' });
}
if (!db.objectStoreNames.contains('translations')) {
db.createObjectStore('translations', { keyPath: 'timestamp' });
}
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
request.onerror = (event) => {
reject('IndexedDB error: ' + event.target.error);
};
});
}
function saveToIndexedDB(storeName, data) {
openIndexedDB().then(db => {
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
store.add(data);
}).catch(error => {
console.error('Error saving to IndexedDB:', error);
});
}
function loadSavedTranslations() {
openIndexedDB().then(db => {
const transaction = db.transaction(['translations'], 'readonly');
const store = transaction.objectStore('translations');
const request = store.getAll();
request.onsuccess = (event) => {
const translations = event.target.result;
if (translations && translations.length > 0) {
// Could add a history section or recently used translations
console.log('Loaded saved translations:', translations.length);
}
};
}).catch(error => {
console.error('Error loading from IndexedDB:', error);
});
}
// PWA installation prompt
function initInstallPrompt() {
let deferredPrompt = null;
const installButton = document.createElement('button');
installButton.style.display = 'none';
installButton.classList.add('btn', 'btn-success', 'fixed-bottom', 'm-3');
installButton.innerHTML = 'Install Voice Translator <i class="fas fa-download ml-2"></i>';
document.body.appendChild(installButton);
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault();
// Stash the event so it can be triggered later
deferredPrompt = e;
// Update UI to notify the user they can add to home screen
installButton.style.display = 'block';
installButton.addEventListener('click', () => {
// Hide our user interface that shows our install button
installButton.style.display = 'none';
// Show the prompt
if (deferredPrompt) {
deferredPrompt.prompt();
// Wait for the user to respond to the prompt
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult.outcome === 'accepted') {
console.log('User accepted the install prompt');
}
else {
console.log('User dismissed the install prompt');
}
deferredPrompt = null;
});
}
});
});
}
// Push notification setup
function setupPushNotifications(swRegistration) {
// Initialize notification UI
initNotificationUI(swRegistration);
// Check saved preference
const notificationsEnabled = localStorage.getItem('notificationsEnabled');
if (notificationsEnabled === 'true' && Notification.permission === 'granted') {
subscribeToPushManager(swRegistration);
}
}
function initNotificationUI(swRegistration) {
const notificationPrompt = document.getElementById('notificationPrompt');
const enableNotificationsBtn = document.getElementById('enableNotifications');
const notificationToggle = document.getElementById('notificationToggle');
const saveSettingsBtn = document.getElementById('saveSettings');
// Check if we should show the prompt
const notificationsDismissed = localStorage.getItem('notificationsDismissed');
const notificationsEnabled = localStorage.getItem('notificationsEnabled');
if (!notificationsDismissed && !notificationsEnabled && Notification.permission === 'default') {
// Show toast after 5 seconds
setTimeout(() => {
const toast = new window.bootstrap.Toast(notificationPrompt);
toast.show();
}, 5000);
}
// Update toggle state
notificationToggle.checked = notificationsEnabled === 'true';
// Enable notifications button
enableNotificationsBtn?.addEventListener('click', async () => {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
localStorage.setItem('notificationsEnabled', 'true');
notificationToggle.checked = true;
await subscribeToPushManager(swRegistration);
const toast = new window.bootstrap.Toast(notificationPrompt);
toast.hide();
// Simple alert for mobile compatibility
setTimeout(() => {
alert('Notifications enabled successfully!');
}, 100);
}
});
// Notification toggle
notificationToggle?.addEventListener('change', async () => {
if (notificationToggle.checked) {
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
notificationToggle.checked = false;
return;
}
}
localStorage.setItem('notificationsEnabled', 'true');
await subscribeToPushManager(swRegistration);
}
else {
localStorage.setItem('notificationsEnabled', 'false');
await unsubscribeFromPushManager(swRegistration);
}
});
// Save settings
saveSettingsBtn?.addEventListener('click', () => {
const notifyTranscription = document.getElementById('notifyTranscription').checked;
const notifyTranslation = document.getElementById('notifyTranslation').checked;
const notifyErrors = document.getElementById('notifyErrors').checked;
const streamingTranslation = document.getElementById('streamingTranslation').checked;
const multiSpeakerMode = document.getElementById('multiSpeakerMode').checked;
localStorage.setItem('notifyTranscription', notifyTranscription.toString());
localStorage.setItem('notifyTranslation', notifyTranslation.toString());
localStorage.setItem('notifyErrors', notifyErrors.toString());
localStorage.setItem('streamingTranslation', streamingTranslation.toString());
localStorage.setItem('multiSpeakerMode', multiSpeakerMode.toString());
// Update multi-speaker mode if changed
const previousMultiSpeakerMode = localStorage.getItem('multiSpeakerMode') === 'true';
if (multiSpeakerMode !== previousMultiSpeakerMode) {
window.location.reload(); // Reload to apply changes
}
// Show inline success message
const saveStatus = document.getElementById('settingsSaveStatus');
if (saveStatus) {
saveStatus.style.display = 'block';
// Hide after 2 seconds and close modal
setTimeout(() => {
saveStatus.style.display = 'none';
const modal = window.bootstrap.Modal.getInstance(document.getElementById('settingsModal'));
modal.hide();
}, 1500);
}
});
// Load saved preferences
const notifyTranscription = document.getElementById('notifyTranscription');
const notifyTranslation = document.getElementById('notifyTranslation');
const notifyErrors = document.getElementById('notifyErrors');
const streamingTranslation = document.getElementById('streamingTranslation');
notifyTranscription.checked = localStorage.getItem('notifyTranscription') !== 'false';
notifyTranslation.checked = localStorage.getItem('notifyTranslation') !== 'false';
notifyErrors.checked = localStorage.getItem('notifyErrors') === 'true';
streamingTranslation.checked = localStorage.getItem('streamingTranslation') !== 'false';
// Initialize cache management UI
initCacheManagement();
}
async function initCacheManagement() {
const cacheCount = document.getElementById('cacheCount');
const cacheSize = document.getElementById('cacheSize');
const offlineMode = document.getElementById('offlineMode');
const clearCacheBtn = document.getElementById('clearCache');
// Load cache stats
async function updateCacheStats() {
const stats = await TranslationCache.getCacheStats();
cacheCount.textContent = stats.totalEntries.toString();
cacheSize.textContent = `${(stats.totalSize / 1024).toFixed(1)} KB`;
}
// Initial load
updateCacheStats();
// Load offline mode preference
offlineMode.checked = localStorage.getItem('offlineMode') !== 'false';
// Toggle offline mode
offlineMode?.addEventListener('change', () => {
localStorage.setItem('offlineMode', offlineMode.checked.toString());
});
// Clear cache button
clearCacheBtn?.addEventListener('click', async () => {
if (confirm('Are you sure you want to clear all cached translations?')) {
await TranslationCache.clearCache();
await updateCacheStats();
alert('Translation cache cleared successfully!');
}
});
// Update stats when modal is shown
const settingsModal = document.getElementById('settingsModal');
settingsModal?.addEventListener('show.bs.modal', updateCacheStats);
}
async function subscribeToPushManager(swRegistration) {
try {
// Get the server's public key
const response = await fetch('/api/push-public-key');
const data = await response.json();
// Convert the base64 string to Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
const convertedVapidKey = urlBase64ToUint8Array(data.publicKey);
// Subscribe to push notifications
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
});
// Send the subscription details to the server
await fetch('/api/push-subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription)
});
console.log('User is subscribed to push notifications');
}
catch (error) {
console.error('Failed to subscribe to push notifications:', error);
}
}
async function unsubscribeFromPushManager(swRegistration) {
try {
const subscription = await swRegistration.pushManager.getSubscription();
if (subscription) {
// Unsubscribe from server
await fetch('/api/push-unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription)
});
// Unsubscribe locally
await subscription.unsubscribe();
console.log('User is unsubscribed from push notifications');
}
}
catch (error) {
console.error('Failed to unsubscribe from push notifications:', error);
}
}
//# sourceMappingURL=app.js.map