diff --git a/frontend/src/components/FloatingTerminal.vue b/frontend/src/components/FloatingTerminal.vue index cde3db8..59c1f50 100644 --- a/frontend/src/components/FloatingTerminal.vue +++ b/frontend/src/components/FloatingTerminal.vue @@ -50,6 +50,10 @@ let terminal: Terminal | null = null let fitAddon: FitAddon | null = null let socket: WebSocket | null = null let resizeObserver: ResizeObserver | null = null +let reconnectTimeout: number | null = null +let reconnectAttempts = 0 +const MAX_RECONNECT_ATTEMPTS = 10 +const RECONNECT_DELAY_MS = 2000 // Buffer for detecting WebMCP token let tokenBuffer = '' @@ -423,12 +427,29 @@ async function connect() { if (connecting.value || connected.value) return connecting.value = true + // Connection timeout - if not connected in 10s, retry + const connectionTimeout = window.setTimeout(() => { + if (connecting.value && !connected.value) { + console.log('[Terminal] Connection timeout, retrying...') + connecting.value = false + socket?.close() + socket = null + if (isOpen.value && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + scheduleReconnect() + } + } + }, 10000) + try { socket = new WebSocket(WS_URL) + // Clear timeout on successful connection + socket.addEventListener('open', () => clearTimeout(connectionTimeout), { once: true }) + socket.onopen = () => { connected.value = true connecting.value = false + reconnectAttempts = 0 // Reset on successful connection terminal?.focus() if (terminal) { socket?.send(JSON.stringify({ @@ -493,6 +514,13 @@ async function connect() { socket.onclose = () => { connected.value = false connecting.value = false + socket = null + + // Auto-reconnect if terminal is still open + if (isOpen.value && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + terminal?.write('\r\n\x1b[33m[Disconnected - reconnecting...]\x1b[0m\r\n') + scheduleReconnect() + } } socket.onerror = () => { @@ -500,9 +528,36 @@ async function connect() { } } catch (e) { connecting.value = false + // Try to reconnect on connection error + if (isOpen.value && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + scheduleReconnect() + } } } +function scheduleReconnect() { + if (reconnectTimeout) clearTimeout(reconnectTimeout) + + reconnectAttempts++ + const delay = RECONNECT_DELAY_MS * Math.min(reconnectAttempts, 5) // Max 10s delay + + console.log(`[Terminal] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`) + + reconnectTimeout = window.setTimeout(() => { + if (isOpen.value && !connected.value && !connecting.value) { + connect() + } + }, delay) +} + +function cancelReconnect() { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + reconnectTimeout = null + } + reconnectAttempts = 0 +} + function close() { isOpen.value = false } @@ -554,6 +609,36 @@ function sendKey(key: string) { } } +// Mobile scroll controls +function scrollTerminal(direction: 'up' | 'down' | 'end') { + if (!terminal) return + + if (direction === 'up') { + terminal.scrollLines(-10) + } else if (direction === 'down') { + terminal.scrollLines(10) + } else if (direction === 'end') { + terminal.scrollToBottom() + } +} + +// Refresh terminal display +function refreshTerminal() { + // If disconnected, try to reconnect + if (!connected.value && !connecting.value) { + reconnectAttempts = 0 + connect() + return + } + + if (!terminal || !fitAddon) return + + // Clear and refit terminal + fitAddon.fit() + terminal.scrollToBottom() + terminal.focus() +} + watch(isOpen, async (open) => { if (open) { await nextTick() @@ -565,6 +650,7 @@ watch(isOpen, async (open) => { }) } else { // Cleanup when closing + cancelReconnect() // Stop any pending reconnection resizeObserver?.disconnect() resizeObserver = null terminal?.dispose() @@ -603,6 +689,7 @@ onMounted(async () => { }) onBeforeUnmount(() => { + cancelReconnect() resizeObserver?.disconnect() socket?.close() terminal?.dispose() @@ -739,6 +826,24 @@ defineExpose({
+ +
+
+ + + + + Desconectado + Toca para reconectar +
+
+ +
+
+
+ Conectando... +
+
@@ -746,6 +851,14 @@ defineExpose({ + + +
+ + + +
+
@@ -880,6 +993,60 @@ defineExpose({ border-radius: 2px; overflow: hidden; background: rgba(0,0,0,0.92); + position: relative; +} + +/* Disconnected/Connecting overlay */ +.disconnect-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + cursor: pointer; +} + +.disconnect-overlay.connecting { + cursor: wait; +} + +.disconnect-msg { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: #fff; + text-align: center; +} + +.disconnect-msg svg { + color: #ef4444; + opacity: 0.8; +} + +.disconnect-msg span { + font-size: 14px; + font-weight: 500; +} + +.disconnect-msg small { + font-size: 11px; + color: #888; +} + +.spinner { + width: 24px; + height: 24px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } } .resize-handle { @@ -913,6 +1080,13 @@ defineExpose({ } .term :deep(.xterm-viewport) { overflow-y: auto !important; + /* Enable touch scrolling on mobile */ + -webkit-overflow-scrolling: touch; + touch-action: pan-y; +} +.term :deep(.xterm-screen) { + /* Allow touch events to pass through for scrolling */ + touch-action: pan-y; } .term :deep(.xterm-viewport::-webkit-scrollbar) { width: 8px; @@ -981,6 +1155,9 @@ defineExpose({ .aero-win.mobile .content { flex: 1; min-height: 100px; + /* Allow touch scrolling in terminal area */ + touch-action: pan-y; + overflow: hidden; } /* Mobile animations */ @@ -1003,14 +1180,14 @@ defineExpose({ } .vk { - min-width: 40px; - height: 32px; - padding: 0 8px; + min-width: 48px; + height: 40px; + padding: 0 10px; background: linear-gradient(180deg, #444 0%, #333 100%); border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 5px; + border-radius: 6px; color: #fff; - font-size: 11px; + font-size: 13px; font-weight: 500; font-family: system-ui, sans-serif; cursor: pointer; @@ -1033,7 +1210,13 @@ defineExpose({ border-color: rgba(100, 150, 255, 0.3); } -.vk-arrows { +.vk.refresh { + background: linear-gradient(180deg, #f59e0b 0%, #d97706 100%); + border-color: rgba(245, 158, 11, 0.3); + font-size: 18px; +} + +.vk-scroll { display: flex; flex-direction: column; align-items: center; @@ -1041,16 +1224,39 @@ defineExpose({ margin-left: auto; } +.vk.scroll { + min-width: 44px; + width: 44px; + height: 36px; + padding: 0; + font-size: 16px; + background: linear-gradient(180deg, #065f46 0%, #047857 100%); + border-color: rgba(16, 185, 129, 0.3); +} + +.vk.scroll.end { + background: linear-gradient(180deg, #7c3aed 0%, #6d28d9 100%); + border-color: rgba(139, 92, 246, 0.3); + font-size: 18px; +} + +.vk-arrows { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + .vk-row { display: flex; gap: 2px; } .vk.arrow { - min-width: 32px; - width: 32px; - height: 26px; + min-width: 38px; + width: 38px; + height: 32px; padding: 0; - font-size: 10px; + font-size: 12px; } diff --git a/frontend/src/components/FloatingVoice.vue b/frontend/src/components/FloatingVoice.vue index be4d940..1ce061a 100644 --- a/frontend/src/components/FloatingVoice.vue +++ b/frontend/src/components/FloatingVoice.vue @@ -46,6 +46,7 @@ const isRecording = ref(false) const transcript = ref('') const interimTranscript = ref('') const error = ref('') +let lastProcessedResult = '' // Track last result to avoid duplicates on Android // Typing animation state const animatedTranscript = ref('') @@ -93,6 +94,7 @@ const showMicSelector = ref(false) // ============ MOBILE DETECTION & AUDIO FORMAT ============ const isMobile = ref(false) +const isAndroid = ref(false) const supportedMimeType = ref('audio/webm;codecs=opus') const sheetHeight = ref(45) // percentage of viewport for mobile const isDraggingSheet = ref(false) @@ -101,7 +103,9 @@ const keyboardHeight = ref(0) const snapPoints = [25, 45, 70] // collapsed, default, expanded function checkMobile() { - isMobile.value = window.innerWidth <= 640 || /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) + const ua = navigator.userAgent + isMobile.value = window.innerWidth <= 640 || /iPhone|iPad|iPod|Android/i.test(ua) + isAndroid.value = /Android/i.test(ua) } // Virtual keyboard detection @@ -338,10 +342,16 @@ function initRecognition() { } const rec = new SpeechRecognition() - rec.continuous = true + // Android Chrome doesn't support continuous mode properly - it stops after first result + // Using non-continuous mode on Android prevents constant restarts and audio loss + rec.continuous = !isAndroid.value rec.interimResults = true rec.lang = 'es-419' // Latin American Spanish (better for accents) + if (isAndroid.value) { + console.log('[Voice] Android detected - using non-continuous mode for stability') + } + rec.onresult = (event: SpeechRecognitionEvent) => { let interim = '' let final = '' @@ -358,7 +368,19 @@ function initRecognition() { } if (final) { - transcript.value += final + // Android: Check for duplicates (same text being processed again) + const trimmedFinal = final.trim() + if (isAndroid.value && lastProcessedResult && trimmedFinal.startsWith(lastProcessedResult.trim())) { + // Only add the new part + const newPart = trimmedFinal.slice(lastProcessedResult.trim().length).trim() + if (newPart) { + transcript.value += newPart + ' ' + lastProcessedResult = trimmedFinal + } + } else { + transcript.value += final + lastProcessedResult = trimmedFinal + } } interimTranscript.value = interim } @@ -375,8 +397,15 @@ function initRecognition() { rec.onend = () => { if (isRecording.value && !useWhisper.value) { - // Restart if still recording (browser stops after silence) - rec.start() + if (isAndroid.value) { + // Android: Don't auto-restart - causes duplicate text + // User should use push-to-talk or tap mic button for each phrase + isRecording.value = false + console.log('[Voice] Android session ended - tap mic to continue') + } else { + // Desktop: Restart immediately (browser stops after silence) + rec.start() + } } } @@ -501,6 +530,11 @@ async function pollWhisperStatus() { } function connectWhisperSocket() { + // Don't connect if Whisper isn't ready + if (!whisperReady.value) { + console.log('[Voice] Whisper not ready, skipping connection') + return + } if (whisperSocket?.readyState === WebSocket.OPEN) return console.log('[Voice] Connecting to Whisper server...') @@ -581,6 +615,20 @@ function disconnectWhisperSocket() { } async function startWhisperRecording() { + // Ensure WebSocket is connected before recording + if (!whisperSocket || whisperSocket.readyState !== WebSocket.OPEN) { + console.warn('[Voice] Whisper socket not connected, attempting to connect...') + connectWhisperSocket() + // Wait a moment for connection + await new Promise(resolve => setTimeout(resolve, 500)) + + if (!whisperSocket || whisperSocket.readyState !== WebSocket.OPEN) { + error.value = 'Whisper server not connected' + canvasStore.showNotification('Whisper not connected. Try toggling GPU mode.', 'error') + return + } + } + try { // Mobile-optimized audio constraints const audioConstraints: MediaTrackConstraints = { @@ -616,9 +664,11 @@ async function startWhisperRecording() { mediaRecorder.start(100) // Collect data every 100ms isRecording.value = true recordingStartTime = Date.now() + console.log(`[Voice] Whisper recording started, socket ready: ${whisperSocket?.readyState === WebSocket.OPEN}`) // Send chunks periodically for progressive transcription chunkInterval = window.setInterval(() => { + console.log(`[Voice] Chunk interval: ${audioChunks.length} chunks, socket: ${whisperSocket?.readyState}`) if (audioChunks.length > 0 && whisperSocket?.readyState === WebSocket.OPEN) { sendAudioChunk(false) // false = partial, don't clear } @@ -633,8 +683,10 @@ async function startWhisperRecording() { function sendAudioChunk(isFinal: boolean) { if (audioChunks.length === 0) return - // Always send ALL accumulated audio (webm needs header from first chunk) - const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }) + // Always send ALL accumulated audio (needs header from first chunk) + const mimeType = mediaRecorder?.mimeType || supportedMimeType.value || 'audio/webm' + const audioBlob = new Blob(audioChunks, { type: mimeType }) + console.log(`[Voice] Sending chunk: ${audioChunks.length} chunks, ${audioBlob.size} bytes, ${mimeType}`) const chunkCount = audioChunks.length // Skip if audio is too small (< 5KB) - WebM header alone is ~1-2KB @@ -719,6 +771,15 @@ function startRecording() { try { recognition.start() isRecording.value = true + + // Notify Android users about limitations + if (isAndroid.value && !isPushToTalk.value) { + canvasStore.showNotification( + 'Android: Tap mic again to continue recording', + 'info', + 3000 + ) + } } catch (e) { console.error('[Voice] Failed to start:', e) } @@ -743,6 +804,7 @@ function clearTranscript() { interimTranscript.value = '' animatedTranscript.value = '' lastAnimatedLength = 0 + lastProcessedResult = '' if (typingTimeout) { clearTimeout(typingTimeout) typingTimeout = null @@ -1025,8 +1087,13 @@ onMounted(async () => { if (status?.starting) { console.log('[Voice] Server is starting, resuming polling...') pollWhisperStatus() - } else if (useWhisper.value) { + } else if (useWhisper.value && whisperReady.value) { + // Only connect if both enabled AND actually running connectWhisperSocket() + } else if (useWhisper.value && !whisperReady.value) { + // User had Whisper enabled but server isn't running - disable it + console.log('[Voice] Whisper was enabled but server not running, disabling') + useWhisper.value = false } }) @@ -1124,8 +1191,8 @@ defineExpose({ Voice - - {{ useWhisper ? 'GPU' : 'Web' }} + + {{ useWhisper ? 'GPU' : (isAndroid ? 'Android' : 'Web') }}
@@ -1350,6 +1417,12 @@ defineExpose({ box-shadow: 0 0 4px rgba(16, 185, 129, 0.5); } +.mode-badge.android { + background: linear-gradient(135deg, #f59e0b, #d97706); + color: #fff; + box-shadow: 0 0 4px rgba(245, 158, 11, 0.5); +} + .whisper-toggle { width: 20px; height: 18px;