diff --git a/static/index.html b/static/index.html index 8257660fe..dc58b4bf4 100644 --- a/static/index.html +++ b/static/index.html @@ -1190,7 +1190,15 @@ - + @@ -2530,9 +2538,17 @@ + + + +
+
+ Idle +
+ diff --git a/static/js/chat.js b/static/js/chat.js index ea2d8c1bb..889e4f7ae 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -758,6 +758,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr submitBtn.dataset.phase = 'processing'; isStreaming = true; _setForegroundChatBusy(true); + window.OdysseusHUD?.setState("thinking"); _startStallWatchdog(); } else if (state === 'idle') { submitBtn.dataset.mode = ''; @@ -2260,6 +2261,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr } // Don't do foreground final render — the checkBackgroundStream poll // will detect 'completed' and reload history cleanly + window.OdysseusHUD?.setState("idle"); break; } // Force-close thinking if still open (model never output boundary) @@ -3941,6 +3943,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId); if (!_isBgFinally) { + if (window.OdysseusHUD?.getState() !== "speaking") { + window.OdysseusHUD?.setState("idle"); + } // Reset button to idle state updateSubmitButton('idle', submitBtn); diff --git a/static/js/hudController.js b/static/js/hudController.js new file mode 100644 index 000000000..9c26da733 --- /dev/null +++ b/static/js/hudController.js @@ -0,0 +1,39 @@ +const validStates = new Set([ + "idle", + "listening", + "thinking", + "speaking", + "acting" +]); + +let currentState = "idle"; + +function setHudState(nextState) { + if (!validStates.has(nextState)) { + console.warn(`Unknown HUD state: ${nextState}`); + return; + } + + currentState = nextState; + + const label = document.querySelector(".odysseus-hud__label"); + if (label) label.textContent = nextState; + document.documentElement.dataset.odysseusState = nextState; + + window.dispatchEvent( + new CustomEvent("odysseus:hud-state", { + detail: { state: nextState } + }) + ); +} + +function getHudState() { + return currentState; +} + +window.OdysseusHUD = { + setState: setHudState, + getState: getHudState +}; + +setHudState("idle"); diff --git a/static/js/micControl.js b/static/js/micControl.js new file mode 100644 index 000000000..fa8aa20cf --- /dev/null +++ b/static/js/micControl.js @@ -0,0 +1,55 @@ +import { + startRecording, + stopRecording, + getIsRecording, +} from "./voiceRecorder.js"; + +function syncButton(button) { + const recording = getIsRecording(); + + button.classList.toggle("is-recording", recording); + button.setAttribute("aria-pressed", String(recording)); + button.setAttribute( + "aria-label", + recording ? "Stop voice recording" : "Start voice recording" + ); + button.title = recording ? "Stop recording" : "Push to talk"; + button.textContent = recording ? "■" : "🎤"; +} + +function initializeMicrophoneButton() { + const button = document.getElementById("voice-record-btn"); + if (!button || button.dataset.connected === "true") return; + + button.dataset.connected = "true"; + syncButton(button); + + button.addEventListener("click", () => { + if (getIsRecording()) { + stopRecording(); + + // Give MediaRecorder.onstop time to update the recording state. + window.setTimeout(() => syncButton(button), 100); + window.setTimeout(() => syncButton(button), 500); + window.setTimeout(() => syncButton(button), 1000); + return; + } + + startRecording( + null, + (message) => console.info(message), + (message) => console.error(message) + ); + + // Microphone access starts asynchronously. + window.setTimeout(() => syncButton(button), 300); + window.setTimeout(() => syncButton(button), 800); + window.setTimeout(() => syncButton(button), 1500); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeMicrophoneButton); +} else { + initializeMicrophoneButton(); +} diff --git a/static/js/tts-ai.js b/static/js/tts-ai.js index 9bb6f4012..cd400492d 100644 --- a/static/js/tts-ai.js +++ b/static/js/tts-ai.js @@ -206,14 +206,17 @@ class AITTSManager { utterance.onend = () => { this.isPlaying = false; + window.OdysseusHUD?.setState("idle"); resolve(); }; utterance.onerror = (e) => { this.isPlaying = false; + window.OdysseusHUD?.setState("idle"); reject(new Error('Browser TTS error: ' + e.error)); }; - window.speechSynthesis.speak(utterance); + window.OdysseusHUD?.setState("speaking"); + window.speechSynthesis.speak(utterance); this.isPlaying = true; }); } diff --git a/static/js/voiceRecorder.js b/static/js/voiceRecorder.js index ec4548632..531972529 100644 --- a/static/js/voiceRecorder.js +++ b/static/js/voiceRecorder.js @@ -94,10 +94,12 @@ function startBrowserSTT() { console.warn('Browser STT error:', e.error); }; + window.OdysseusHUD?.setState("listening"); _recognition.start(); } function stopBrowserSTT() { + window.OdysseusHUD?.setState("thinking"); if (_recognition) { try { _recognition.stop(); } catch (e) { /* ignore */ } _recognition = null; @@ -109,22 +111,35 @@ function stopBrowserSTT() { * Send audio to server for transcription */ async function transcribeOnServer(audioBlob) { - const formData = new FormData(); - formData.append('file', audioBlob, 'audio.webm'); + const formData = new FormData(); + formData.append('file', audioBlob, 'audio.webm'); - const res = await fetch('/api/stt/transcribe', { - method: 'POST', - credentials: 'same-origin', - body: formData, - }); + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 20000); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.detail?.message || 'Transcription failed'); - } + try { + const res = await fetch('/api/stt/transcribe', { + method: 'POST', + credentials: 'same-origin', + body: formData, + signal: controller.signal, + }); - const data = await res.json(); - return data.text || ''; + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail?.message || 'Transcription failed'); + } + + const data = await res.json(); + return data.text || ''; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('Transcription timed out after 20 seconds'); + } + throw error; + } finally { + window.clearTimeout(timeoutId); + } } /** @@ -152,6 +167,7 @@ export function startRecording(onFileCreated, showToast, showError) { // Check for secure context (getUserMedia requires HTTPS or localhost) if (!window.isSecureContext) { if (showError) showError('Microphone requires HTTPS. Use a reverse proxy with SSL or access via localhost.'); + window.OdysseusHUD?.setState("idle"); _resetRecordingUI(); return; } @@ -175,6 +191,8 @@ export function startRecording(onFileCreated, showToast, showError) { }; mediaRecorder.onstop = async () => { + window.OdysseusHUD?.setState("thinking"); + const hudThinkingStartedAt = Date.now(); stream.getTracks().forEach(track => track.stop()); const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); @@ -212,12 +230,20 @@ export function startRecording(onFileCreated, showToast, showError) { if (onFileCreated) onFileCreated(audioFile); } + const hudElapsed = Date.now() - hudThinkingStartedAt; + if (hudElapsed < 800) { + await new Promise(resolve => + window.setTimeout(resolve, 800 - hudElapsed) + ); + } + window.OdysseusHUD?.setState("idle"); _resetRecordingUI(); }; mediaRecorder.start(); isRecording = true; recordingStartTime = new Date(); + window.OdysseusHUD?.setState("listening"); // Start browser STT if that's the provider if (_sttProvider === 'browser') { diff --git a/static/style.css b/static/style.css index 73fdbcd5b..bacced147 100644 --- a/static/style.css +++ b/static/style.css @@ -41130,3 +41130,99 @@ body.theme-frosted .modal { .compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; } .compare-pane { min-height: 60dvh; } } + +/* ================================ + ODYSSEUS HUD +================================ */ + +.odysseus-hud { + position: fixed; + top: 20px; + right: 24px; + z-index: 9999; + + display: flex; + align-items: center; + gap: 10px; + + padding: 10px 14px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 999px; + + background: rgba(8, 12, 20, 0.72); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + + color: white; + font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; + + box-shadow: + 0 0 18px rgba(67, 156, 255, 0.22), + inset 0 0 12px rgba(255, 255, 255, 0.04); +} + +.odysseus-hud__core { + width: 14px; + height: 14px; + border-radius: 50%; + + background: #63a9ff; + + box-shadow: + 0 0 8px #63a9ff, + 0 0 18px rgba(99, 169, 255, 0.75); + + animation: odysseus-idle-pulse 2.4s ease-in-out infinite; +} + +.odysseus-hud__label { + line-height: 1; + opacity: 0.92; +} + +@keyframes odysseus-idle-pulse { + 0%, + 100% { + transform: scale(0.92); + opacity: 0.72; + } + + 50% { + transform: scale(1.12); + opacity: 1; + } +} + + +/* ================================ + ODYSSEUS MICROPHONE CONTROL +================================ */ + +.voice-record-btn { + width: 38px; + height: 38px; + flex: 0 0 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 50%; + background: rgba(8, 12, 20, 0.7); + color: white; + cursor: pointer; + font-size: 16px; + line-height: 1; +} + +.voice-record-btn:hover { + border-color: rgba(99, 169, 255, 0.7); + box-shadow: 0 0 12px rgba(99, 169, 255, 0.3); +} + +.voice-record-btn.is-recording { + background: rgba(160, 24, 32, 0.85); + box-shadow: 0 0 16px rgba(255, 70, 80, 0.55); +}