diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui index 52803505..29e69ad3 100644 --- a/dockerfiles/Dockerfile.hermes-webui +++ b/dockerfiles/Dockerfile.hermes-webui @@ -92,6 +92,7 @@ PY COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js +COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py @@ -109,6 +110,8 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ && grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \ && grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \ && grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \ + && grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/atlas-voice.css \ + && grep -Fq 'id="voiceInstrumentStyles"' /opt/hermes-webui/static/index.html \ && grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \ && grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \ && grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \ diff --git a/dockerfiles/hermes-webui-atlas-patch.py b/dockerfiles/hermes-webui-atlas-patch.py index 20d04399..d7bccf63 100644 --- a/dockerfiles/hermes-webui-atlas-patch.py +++ b/dockerfiles/hermes-webui-atlas-patch.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 """Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI.""" +import os from pathlib import Path -ROOT = Path("/opt/hermes-webui") +ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui")) def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None: @@ -16,6 +17,13 @@ def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None: index = ROOT / "static/index.html" +replace_exact( + index, + '', + '\n' + '', +) replace_exact( index, '', @@ -26,6 +34,32 @@ replace_exact( '', '\n', ) +replace_exact( + index, + '''
''', + ''' ''', +) ui = ROOT / "static/ui.js" replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){") diff --git a/dockerfiles/hermes-webui-atlas-voice.css b/dockerfiles/hermes-webui-atlas-voice.css new file mode 100644 index 00000000..910fbfad --- /dev/null +++ b/dockerfiles/hermes-webui-atlas-voice.css @@ -0,0 +1,336 @@ +/* Private hands-free conversation instrument for the Atlas voice bridge. */ +.voice-mode-bar { + --voice-accent: 72, 207, 204; + --voice-accent-secondary: 76, 164, 205; + position: relative; + isolation: isolate; + box-sizing: border-box; + min-height: 106px; + padding: 9px 12px 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + overflow: hidden; + border-bottom: 1px solid rgba(174, 192, 218, 0.1); + background: + radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.075), transparent 46%), + linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(4, 8, 18, 0.035)); +} + +.voice-mode-bar::before { + content: ""; + position: absolute; + z-index: -1; + top: 0; + left: 18%; + right: 18%; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(233, 244, 255, 0.16), transparent); +} + +:root:not(.dark) .voice-mode-bar { + border-bottom-color: rgba(38, 55, 78, 0.1); + background: + radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.09), transparent 48%), + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(226, 232, 240, 0.12)); +} + +.voice-mode-indicator { + --voice-ripple-scale: 1.035; + --voice-ripple-opacity: 0.3; + position: relative; + width: 70px; + height: 70px; + flex: 0 0 70px; + border-radius: 50%; + transform: translateZ(0); +} + +.voice-mode-indicator.listening { + --voice-accent: 69, 218, 207; + --voice-accent-secondary: 73, 169, 209; +} + +.voice-mode-indicator.transcribing { + --voice-accent: 73, 193, 218; + --voice-accent-secondary: 93, 129, 224; +} + +.voice-mode-indicator.thinking { + --voice-accent: 135, 111, 244; + --voice-accent-secondary: 81, 62, 190; +} + +.voice-mode-indicator.speaking { + --voice-accent: 249, 169, 105; + --voice-accent-secondary: 236, 112, 137; +} + +.voice-mode-indicator.error { + --voice-accent: 235, 135, 88; + --voice-accent-secondary: 198, 74, 78; +} + +.voice-instrument-halo, +.voice-instrument-ripple, +.voice-instrument-orbit, +.voice-instrument-core { + position: absolute; + display: block; + box-sizing: border-box; + border-radius: 50%; + pointer-events: none; +} + +.voice-instrument-halo { + inset: 2px; + opacity: 0; + background: radial-gradient(circle, rgba(var(--voice-accent), 0.2), rgba(var(--voice-accent), 0.05) 43%, transparent 70%); + filter: blur(4px); +} + +.voice-instrument-ripple { + inset: 3px; + opacity: 0; + border: 1px solid rgba(var(--voice-accent), 0.56); + box-shadow: 0 0 12px rgba(var(--voice-accent), 0.13); +} + +.voice-instrument-orbit { + inset: 2px; + opacity: 0; + border: 1px solid rgba(var(--voice-accent), 0.18); + box-shadow: + inset 0 0 8px rgba(var(--voice-accent), 0.06), + 0 0 11px rgba(var(--voice-accent), 0.07); +} + +.voice-instrument-orbit::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + border-top: 1.5px solid rgba(var(--voice-accent), 0.88); + border-right: 1px solid rgba(var(--voice-accent-secondary), 0.35); + border-bottom: 1px solid transparent; + border-left: 1px solid transparent; +} + +.voice-instrument-orbit::after { + content: ""; + position: absolute; + top: -2px; + left: 50%; + width: 5px; + height: 5px; + margin-left: -2.5px; + border-radius: 50%; + background: rgb(var(--voice-accent)); + box-shadow: + 0 0 5px rgba(var(--voice-accent), 0.92), + 0 0 12px rgba(var(--voice-accent), 0.44); +} + +.voice-instrument-core { + inset: 12px; + display: grid; + place-items: center; + color: rgba(242, 250, 255, 0.94); + border: 1px solid rgba(238, 247, 255, 0.2); + background: + radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.26), transparent 24%), + radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.36), transparent 64%), + linear-gradient(145deg, rgba(36, 45, 61, 0.96), rgba(10, 15, 27, 0.98)); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.22), + inset 0 -9px 18px rgba(0, 0, 0, 0.24), + 0 4px 13px rgba(0, 0, 0, 0.25), + 0 0 17px rgba(var(--voice-accent), 0.17); +} + +:root:not(.dark) .voice-instrument-core { + color: rgba(31, 48, 66, 0.9); + border-color: rgba(255, 255, 255, 0.82); + background: + radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.96), transparent 27%), + radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.2), transparent 65%), + linear-gradient(145deg, rgba(248, 250, 251, 0.98), rgba(216, 225, 231, 0.96)); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.94), + inset 0 -8px 17px rgba(64, 84, 100, 0.1), + 0 4px 12px rgba(35, 53, 72, 0.15), + 0 0 15px rgba(var(--voice-accent), 0.13); +} + +.voice-instrument-symbol { + display: block; + width: 22px; + height: 22px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.32)); +} + +.voice-instrument-symbol svg { + display: block; + width: 100%; + height: 100%; +} + +.voice-symbol { + display: none; +} + +.voice-mode-indicator.listening .voice-symbol-listening, +.voice-mode-indicator.transcribing .voice-symbol-transcribing, +.voice-mode-indicator.thinking .voice-symbol-thinking, +.voice-mode-indicator.speaking .voice-symbol-speaking, +.voice-mode-indicator.error .voice-symbol-error { + display: inline; +} + +.voice-mode-indicator.listening .voice-instrument-halo { + opacity: 0.62; + animation: voice-instrument-breathe 3.8s ease-in-out infinite; +} + +.voice-mode-indicator.listening .voice-instrument-ripple { + opacity: var(--voice-ripple-opacity); + transform: scale(var(--voice-ripple-scale)); + transition: transform 140ms ease-out, opacity 160ms ease-out; +} + +.voice-mode-indicator.transcribing .voice-instrument-orbit { + opacity: 0.92; + animation: voice-instrument-orbit 3.4s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite; +} + +.voice-mode-indicator.thinking .voice-instrument-orbit { + opacity: 0.96; + animation: voice-instrument-orbit 4.6s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite; +} + +.voice-mode-indicator.thinking .voice-instrument-core { + animation: voice-instrument-core-breathe 5.7s ease-in-out infinite; +} + +.voice-mode-indicator.speaking .voice-instrument-orbit { + opacity: 0.86; + transform: rotate(38deg); +} + +.voice-mode-indicator.speaking.is-playing .voice-instrument-halo { + opacity: 0.66; + animation: voice-instrument-speaking-pulse 2.15s ease-out infinite; +} + +.voice-mode-indicator.error .voice-instrument-orbit { + opacity: 0.9; + transform: rotate(-32deg); + border-color: rgba(var(--voice-accent), 0.44); +} + +.voice-mode-indicator.error .voice-instrument-core { + color: rgba(255, 232, 219, 0.95); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.2), + inset 0 -9px 18px rgba(0, 0, 0, 0.24), + 0 4px 13px rgba(0, 0, 0, 0.25), + 0 0 12px rgba(var(--voice-accent), 0.17); +} + +.voice-mode-label { + position: relative; + z-index: 1; + min-height: 16px; + max-width: min(100%, 34rem); + overflow: hidden; + color: var(--text, #f4f6fb); + font-size: 12px; + font-weight: 650; + line-height: 1.35; + letter-spacing: 0.018em; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); +} + +:root:not(.dark) .voice-mode-label { + color: #263648; + text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8); +} + +@keyframes voice-instrument-breathe { + 0%, 100% { opacity: 0.38; transform: scale(0.94); } + 50% { opacity: 0.72; transform: scale(1.06); } +} + +@keyframes voice-instrument-orbit { + 0% { transform: rotate(0deg); } + 50% { transform: rotate(177deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes voice-instrument-core-breathe { + 0%, 100% { filter: saturate(0.96) brightness(0.98); } + 50% { filter: saturate(1.08) brightness(1.06); } +} + +@keyframes voice-instrument-speaking-pulse { + 0% { opacity: 0.58; transform: scale(0.88); } + 58% { opacity: 0.22; transform: scale(1.12); } + 100% { opacity: 0; transform: scale(1.2); } +} + +@media (max-width: 640px) { + .voice-mode-bar { + min-height: 94px; + padding: 7px 10px 8px; + gap: 4px; + } + + .voice-mode-indicator { + width: 62px; + height: 62px; + flex-basis: 62px; + } + + .voice-instrument-core { + inset: 11px; + } + + .voice-instrument-symbol { + width: 20px; + height: 20px; + } +} + +@media (prefers-reduced-motion: reduce) { + .voice-mode-bar *, + .voice-mode-bar *::before, + .voice-mode-bar *::after { + animation: none !important; + transition: none !important; + } + + .voice-mode-indicator.listening .voice-instrument-halo { + opacity: 0.45; + transform: none; + } + + .voice-mode-indicator.listening .voice-instrument-ripple { + opacity: 0.34; + transform: scale(1.035); + } + + .voice-mode-indicator.transcribing .voice-instrument-orbit, + .voice-mode-indicator.thinking .voice-instrument-orbit { + transform: rotate(24deg); + } + + .voice-mode-indicator.speaking.is-playing .voice-instrument-halo { + opacity: 0.42; + transform: scale(1.03); + } +} diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index e83c35b7..707e3457 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -19,6 +19,18 @@ let vadTimer=null; let currentAudio=null; let thinkingSession=null; + let errorTimer=null; + let visualInputLevel=0; + const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false}; + const ERROR_VISIBLE_MS=3200; + const STATE_LABELS={ + listening:'Listening', + transcribing:'Transcribing…', + thinking:'Thinking…', + speaking:'Speaking', + error:'Voice unavailable', + idle:'', + }; const originalAutoRead=window.autoReadLastAssistant; const originalApplyPreference=window._applyVoiceModePref; @@ -29,8 +41,48 @@ function setState(next, customLabel){ state=next; indicator.className='voice-mode-indicator '+next; - label.textContent=customLabel||(next==='listening'?'Listening…':next==='speaking'?'Speaking…':next==='thinking'?'Thinking…':''); - bar.style.display=active&&next!=='idle'?'':'none'; + bar.dataset.voiceState=next; + bar.setAttribute('aria-busy',next==='transcribing'||next==='thinking'?'true':'false'); + label.textContent=customLabel||STATE_LABELS[next]||''; + bar.style.display=(active&&next!=='idle')||next==='error'?'':'none'; + resetInputLevel(); + } + + function resetInputLevel(){ + visualInputLevel=0; + indicator.style.setProperty('--voice-ripple-scale','1.035'); + indicator.style.setProperty('--voice-ripple-opacity','0.3'); + } + + function updateInputLevel(rms){ + if(reducedMotion.matches||state!=='listening') return; + const target=Math.max(0,Math.min(1,(rms-0.01)/0.18)); + visualInputLevel=(visualInputLevel*0.72)+(target*0.28); + indicator.style.setProperty('--voice-ripple-scale',(1.035+(visualInputLevel*0.16)).toFixed(3)); + indicator.style.setProperty('--voice-ripple-opacity',(0.26+(visualInputLevel*0.48)).toFixed(3)); + } + + function clearErrorTimer(){ + if(errorTimer){window.clearTimeout(errorTimer);errorTimer=null;} + } + + function errorMessage(error, fallback){ + return String((error&&error.message)||fallback||'Voice unavailable').trim(); + } + + function showUnavailable(message){ + generation+=1; + active=false; + thinkingSession=null; + stopCapture(); + stopPlayback(); + modeBtn.classList.remove('active'); + setState('error',message); + clearErrorTimer(); + errorTimer=window.setTimeout(function(){ + errorTimer=null; + if(!active&&state==='error'&&indicator.classList.contains('error')) setState('idle'); + },ERROR_VISIBLE_MS); } function stopCapture(){ @@ -47,17 +99,18 @@ if(!currentAudio) return; try{currentAudio.pause();currentAudio.currentTime=0;}catch(_){ } currentAudio=null; + indicator.classList.remove('is-playing'); } function deactivate(showMessage){ generation+=1; active=false; - state='idle'; thinkingSession=null; + clearErrorTimer(); stopCapture(); stopPlayback(); modeBtn.classList.remove('active'); - bar.style.display='none'; + setState('idle'); if(showMessage) toast('Hands-free voice mode off'); } @@ -80,7 +133,7 @@ async function transcribe(blob, token){ if(!active||token!==generation) return; - setState('thinking','Transcribing…'); + setState('transcribing'); const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm'; const form=new FormData(); form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext})); @@ -91,8 +144,9 @@ sendTranscript(payload.transcript,token); }catch(error){ if(!active||token!==generation) return; - deactivate(false); - toast((error&&error.message)||'Private Whisper is unavailable'); + const message=errorMessage(error,'Private Whisper is unavailable'); + showUnavailable(message); + toast(message); // If the browser supplies its own recognizer, hand control back to the // upstream voice implementation until the Jetson becomes healthy again. if(window.SpeechRecognition||window.webkitSpeechRecognition){ @@ -166,6 +220,7 @@ energy+=normalized*normalized; } const rms=Math.sqrt(energy/samples.length); + updateInputLevel(rms); const now=Date.now(); const speechThreshold=Math.max(0.04,noiseFloor*2.4+0.006); const voiceNow=rms>speechThreshold; @@ -188,8 +243,9 @@ },100); }catch(error){ if(!active||token!==generation) return; - deactivate(false); - toast((error&&error.message)||'Microphone permission is required'); + const message=errorMessage(error,'Microphone permission is required'); + showUnavailable(message); + toast(message); } } @@ -206,11 +262,14 @@ currentAudio=audio; function cleanup(){ if(currentAudio===audio) currentAudio=null; + indicator.classList.remove('is-playing'); URL.revokeObjectURL(url); } audio.onended=function(){cleanup();resolve();}; audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));}; - audio.play().catch(function(error){cleanup();reject(error);}); + audio.play().then(function(){ + if(active&&token===generation&¤tAudio===audio) indicator.classList.add('is-playing'); + }).catch(function(error){cleanup();reject(error);}); }); } @@ -251,7 +310,11 @@ await playBlob(blob,token); } }catch(error){ - if(active&&token===generation) toast((error&&error.message)||'Local speech is unavailable'); + if(active&&token===generation){ + const message=errorMessage(error,'Local speech is unavailable'); + setState('error',message); + toast(message); + } } restartSoon(token,450); } @@ -260,6 +323,7 @@ generation+=1; const token=generation; active=true; + clearErrorTimer(); modeBtn.classList.add('active'); toast('Hands-free private voice mode on'); if(typeof window.stopTTS==='function') window.stopTTS(); diff --git a/services/maintenance/apps/metis-configmap.yaml b/services/maintenance/apps/metis-configmap.yaml index 8c8e0722..0e26e31a 100644 --- a/services/maintenance/apps/metis-configmap.yaml +++ b/services/maintenance/apps/metis-configmap.yaml @@ -15,8 +15,8 @@ data: METIS_MAX_DEVICE_BYTES: "1000000000000" METIS_NAMESPACE: maintenance METIS_REMOTE_POD_TIMEOUT_SEC: "14400" - METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-303-amd64 # {"$imagepolicy": "maintenance:metis-amd64"} - METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-arm64"} + METIS_RUNNER_IMAGE_AMD64: registry.bstein.dev/bstein/metis:0.1.0-304-amd64 # {"$imagepolicy": "maintenance:metis-amd64"} + METIS_RUNNER_IMAGE_ARM64: registry.bstein.dev/bstein/metis:0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-arm64"} METIS_HARBOR_REGISTRY: registry.bstein.dev METIS_HARBOR_PROJECT: metis METIS_HARBOR_API_BASE: https://registry.bstein.dev/api/v2.0 diff --git a/services/maintenance/kustomization.yaml b/services/maintenance/kustomization.yaml index 0a5c49b6..8a5e9fde 100644 --- a/services/maintenance/kustomization.yaml +++ b/services/maintenance/kustomization.yaml @@ -13,6 +13,6 @@ images: - name: registry.bstein.dev/bstein/ariadne newTag: 0.1.0-464 # {"$imagepolicy": "maintenance:ariadne:tag"} - name: registry.bstein.dev/bstein/metis - newTag: 0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"} + newTag: 0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-arm64:tag"} - name: registry.bstein.dev/bstein/soteria newTag: 0.1.0-120 # {"$imagepolicy": "maintenance:soteria:tag"} diff --git a/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml b/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml index d275070f..64cfb8e6 100644 --- a/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml +++ b/services/maintenance/node-ops/metis-sentinel-amd64-daemonset.yaml @@ -32,7 +32,7 @@ spec: kubernetes.io/arch: amd64 containers: - name: metis-sentinel - image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-303-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"} + image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-304-amd64 # {"$imagepolicy": "maintenance:metis-sentinel-amd64"} imagePullPolicy: Always envFrom: - configMapRef: diff --git a/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml b/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml index 18af794e..398018dc 100644 --- a/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml +++ b/services/maintenance/node-ops/metis-sentinel-arm64-daemonset.yaml @@ -32,7 +32,7 @@ spec: kubernetes.io/arch: arm64 containers: - name: metis-sentinel - image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-303-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"} + image: registry.bstein.dev/bstein/metis-sentinel:0.1.0-304-arm64 # {"$imagepolicy": "maintenance:metis-sentinel-arm64"} imagePullPolicy: Always envFrom: - configMapRef: diff --git a/testing/fixtures/hermes-webui-0.52.181/SOURCE.md b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md new file mode 100644 index 00000000..12e0e76b --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/SOURCE.md @@ -0,0 +1,12 @@ +# Hermes WebUI fixture provenance + +These minimal files contain the exact patch-context fragments extracted from the +Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`: + +- Image: `ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2` +- Version: `0.52.181` +- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98` +- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c` + +The fixture stays intentionally narrow, but tests execute the shipped +`hermes-webui-atlas-patch.py` against it; they do not duplicate its patch logic. diff --git a/testing/fixtures/hermes-webui-0.52.181/api/routes.py b/testing/fixtures/hermes-webui-0.52.181/api/routes.py new file mode 100644 index 00000000..de5cf520 --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/api/routes.py @@ -0,0 +1,3 @@ +def tts(handler, engine): + # ── ElevenLabs TTS ────────────────────────────────────────────────── + return False diff --git a/testing/fixtures/hermes-webui-0.52.181/static/index.html b/testing/fixtures/hermes-webui-0.52.181/static/index.html new file mode 100644 index 00000000..6eb7f63d --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/static/index.html @@ -0,0 +1,18 @@ + + + + + + + +