Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d8a88680e | ||
|
|
ae90cf620b | ||
|
|
8b5c8e32fd | ||
|
|
f61f88f16b | ||
|
|
1ae56799cb | ||
|
|
a31466b1a3 | ||
|
|
6e6931a8c3 | ||
|
|
6a13feaf8b | ||
|
|
f6ed8f7302 | ||
|
|
a8137ff21c | ||
|
|
4ba4f77343 | ||
|
|
09f19d1233 | ||
|
|
f4a6544121 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -3,6 +3,18 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.386] — 2026-06-13 — Release MY (voice mode survives a dropped speechSynthesis onend, #3983)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Hands-free voice mode no longer dead-ends after the first browser-TTS reply (#3983).** Chromium intermittently drops the `speechSynthesis` utterance's `onend` event, which left voice mode stuck "speaking" and never re-armed listening. A watchdog now forces a return to listening if `onend` never fires, with the recovery handles cleared on normal completion and on deactivation. The fix is scoped to the browser `speechSynthesis` path — the Edge `Audio` branch (which has a reliable `onended`) is untouched. (#3983)
|
||||
|
||||
## [v0.51.385] — 2026-06-13 — Release MX (profile-cookie env var aligned to HERMES_WEBUI_ prefix, #803)
|
||||
|
||||
### Changed
|
||||
|
||||
- **The profile-cookie name env var now uses the standard `HERMES_WEBUI_` prefix (#803).** Set the per-instance session-profile cookie name via `HERMES_WEBUI_PROFILE_COOKIE_NAME`, matching every other WebUI setting's prefix; the original `WEBUI_PROFILE_COOKIE_NAME` keeps working as a deprecated fallback (warned once per process). Lets multiple WebUI instances on the same host+port disambiguate their profile cookies without env-var-naming surprises. (#803)
|
||||
|
||||
## [v0.51.384] — 2026-06-13 — Release MW (no false streaming / activity-timer reset on session switch, #3900)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -495,11 +495,36 @@ def read_body(handler) -> dict:
|
||||
# ── Profile cookie helpers (issue #798) ─────────────────────────────────────
|
||||
|
||||
PROFILE_COOKIE_NAME = 'hermes_profile'
|
||||
_PROFILE_COOKIE_ENV = 'HERMES_WEBUI_PROFILE_COOKIE_NAME'
|
||||
_LEGACY_PROFILE_COOKIE_ENV = 'WEBUI_PROFILE_COOKIE_NAME'
|
||||
_legacy_profile_cookie_warned = False
|
||||
|
||||
|
||||
def get_profile_cookie_name() -> str:
|
||||
"""Return the cookie name used to persist the active WebUI profile."""
|
||||
return os.getenv('WEBUI_PROFILE_COOKIE_NAME', PROFILE_COOKIE_NAME)
|
||||
"""Return the cookie name used to persist the active WebUI profile.
|
||||
|
||||
Honours ``HERMES_WEBUI_PROFILE_COOKIE_NAME`` so multiple WebUI instances
|
||||
sharing a hostname (different ports) can use distinct profile-cookie names
|
||||
instead of trampling each other; browsers scope cookies by host, not
|
||||
host+port (RFC 6265). The original ``WEBUI_PROFILE_COOKIE_NAME`` is still
|
||||
honoured as a deprecated fallback (warned once per process, since this is
|
||||
called on every request).
|
||||
"""
|
||||
name = os.getenv(_PROFILE_COOKIE_ENV, '').strip()
|
||||
if name:
|
||||
return name
|
||||
legacy = os.getenv(_LEGACY_PROFILE_COOKIE_ENV, '').strip()
|
||||
if legacy:
|
||||
global _legacy_profile_cookie_warned
|
||||
if not _legacy_profile_cookie_warned:
|
||||
logger.warning(
|
||||
'%s is deprecated; use %s instead.',
|
||||
_LEGACY_PROFILE_COOKIE_ENV,
|
||||
_PROFILE_COOKIE_ENV,
|
||||
)
|
||||
_legacy_profile_cookie_warned = True
|
||||
return legacy
|
||||
return PROFILE_COOKIE_NAME
|
||||
|
||||
|
||||
def get_profile_cookie(handler) -> str | None:
|
||||
|
||||
@@ -7307,6 +7307,27 @@ def _run_agent_streaming(
|
||||
requested_model=resolved_model or model,
|
||||
requested_provider=resolved_provider,
|
||||
)
|
||||
# Synthesize routing metadata from the agent's result when the
|
||||
# hermes fallback chain switched provider/model but didn't
|
||||
# populate llm_gateway_metadata (result carries 'model'/'provider',
|
||||
# not 'used_model'/'used_provider' that _extract_... expects).
|
||||
if not _gateway_routing and isinstance(result, dict):
|
||||
_result_model = result.get('model') or ''
|
||||
_result_provider = result.get('provider') or ''
|
||||
_req_model = resolved_model or model or ''
|
||||
_req_provider = resolved_provider or ''
|
||||
if (_result_model and _result_model != _req_model) or \
|
||||
(_result_provider and _req_provider and _result_provider != _req_provider):
|
||||
_gateway_routing = _normalize_gateway_routing_metadata(
|
||||
{
|
||||
'used_model': _result_model,
|
||||
'used_provider': _result_provider,
|
||||
'requested_model': _req_model,
|
||||
'requested_provider': _req_provider,
|
||||
},
|
||||
requested_model=_req_model,
|
||||
requested_provider=_req_provider,
|
||||
)
|
||||
if _gateway_routing:
|
||||
s.gateway_routing = _gateway_routing
|
||||
_history = list(getattr(s, 'gateway_routing_history', None) or [])
|
||||
|
||||
@@ -853,8 +853,48 @@ window._micPendingSend=window._micPendingSend||false;
|
||||
// a different session's last assistant reply if the user navigated away
|
||||
// between send and stream completion. (Opus pre-release advisor.)
|
||||
let _voiceModeThinkingSid=null;
|
||||
let _browserTtsKeepAlive=null;
|
||||
let _browserTtsWatchdog=null;
|
||||
let _browserTtsSuppressNextErrorRearm=false;
|
||||
const SILENCE_MS=1800; // auto-send after 1.8s silence
|
||||
|
||||
function _clearBrowserTtsRecovery(){
|
||||
if(_browserTtsKeepAlive){
|
||||
clearInterval(_browserTtsKeepAlive);
|
||||
_browserTtsKeepAlive=null;
|
||||
}
|
||||
if(_browserTtsWatchdog){
|
||||
clearTimeout(_browserTtsWatchdog);
|
||||
_browserTtsWatchdog=null;
|
||||
}
|
||||
}
|
||||
|
||||
function _armBrowserTtsRecovery(clean, rate){
|
||||
_clearBrowserTtsRecovery();
|
||||
_browserTtsSuppressNextErrorRearm=false;
|
||||
const safeRate=(Number.isFinite(rate)&&rate>0)?rate:1;
|
||||
// Chromium can drop utter.onend on later turns, so force a recovery path.
|
||||
const watchdogMs=Math.max(4000,Math.round((String(clean||'').length/(12*safeRate))*1000)+10000);
|
||||
_browserTtsWatchdog=setTimeout(()=>{
|
||||
if(!_voiceModeActive||_voiceModeState!=='speaking') return;
|
||||
_browserTtsSuppressNextErrorRearm=true;
|
||||
try{ speechSynthesis.cancel(); }catch(_){}
|
||||
_clearBrowserTtsRecovery();
|
||||
_startListening();
|
||||
},watchdogMs);
|
||||
_browserTtsKeepAlive=setInterval(()=>{
|
||||
if(!_voiceModeActive||_voiceModeState!=='speaking'){
|
||||
_clearBrowserTtsRecovery();
|
||||
return;
|
||||
}
|
||||
if(!speechSynthesis.speaking) return;
|
||||
try{
|
||||
speechSynthesis.pause();
|
||||
speechSynthesis.resume();
|
||||
}catch(_){}
|
||||
},10000);
|
||||
}
|
||||
|
||||
function _setState(state){
|
||||
_voiceModeState=state;
|
||||
indicator.className='voice-mode-indicator '+state;
|
||||
@@ -867,6 +907,7 @@ window._micPendingSend=window._micPendingSend||false;
|
||||
|
||||
function _startListening(){
|
||||
if(!_voiceModeActive) return;
|
||||
_clearBrowserTtsRecovery();
|
||||
_setState('listening');
|
||||
|
||||
_recognition=new SpeechRecognition();
|
||||
@@ -1057,14 +1098,27 @@ window._micPendingSend=window._micPendingSend||false;
|
||||
if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
|
||||
|
||||
utter.onend=()=>{
|
||||
_browserTtsSuppressNextErrorRearm=false;
|
||||
_clearBrowserTtsRecovery();
|
||||
// After speaking, go back to listening
|
||||
if(_voiceModeActive) setTimeout(()=>_startListening(),500);
|
||||
if(_voiceModeActive&&_voiceModeState==='speaking') setTimeout(()=>_startListening(),500);
|
||||
};
|
||||
utter.onerror=()=>{
|
||||
_clearBrowserTtsRecovery();
|
||||
if(_browserTtsSuppressNextErrorRearm){
|
||||
_browserTtsSuppressNextErrorRearm=false;
|
||||
return;
|
||||
}
|
||||
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
|
||||
};
|
||||
|
||||
speechSynthesis.speak(utter);
|
||||
_armBrowserTtsRecovery(clean, utter.rate);
|
||||
try{
|
||||
speechSynthesis.speak(utter);
|
||||
}catch(_){
|
||||
_clearBrowserTtsRecovery();
|
||||
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Hook into response completion — observe when the agent finishes
|
||||
@@ -1121,10 +1175,12 @@ window._micPendingSend=window._micPendingSend||false;
|
||||
_voiceModeActive=false;
|
||||
_voiceModeState='idle';
|
||||
_voiceModeThinkingSid=null;
|
||||
_browserTtsSuppressNextErrorRearm=false;
|
||||
modeBtn.classList.remove('active');
|
||||
_setButtonTooltip(modeBtn, t('voice_mode_toggle'));
|
||||
bar.style.display='none';
|
||||
clearTimeout(_silenceTimer);
|
||||
_clearBrowserTtsRecovery();
|
||||
try{ if(_recognition) _recognition.abort(); }catch(_){}
|
||||
_recognition=null;
|
||||
if(typeof stopTTS==='function') stopTTS();
|
||||
|
||||
82
tests/test_issue3983_browser_tts_watchdog.py
Normal file
82
tests/test_issue3983_browser_tts_watchdog.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _extract_function(src: str, name: str) -> str:
|
||||
anchor = f"function {name}("
|
||||
start = src.find(anchor)
|
||||
assert start != -1, f"{name}() must exist"
|
||||
body_start = src.find("{", start)
|
||||
assert body_start != -1, f"{name}() must have a body"
|
||||
depth = 1
|
||||
idx = body_start + 1
|
||||
while depth and idx < len(src):
|
||||
if src[idx] == "{":
|
||||
depth += 1
|
||||
elif src[idx] == "}":
|
||||
depth -= 1
|
||||
idx += 1
|
||||
assert depth == 0, f"{name}() body must balance braces"
|
||||
return src[start:idx]
|
||||
|
||||
|
||||
def test_boot_js_declares_browser_tts_recovery_helpers():
|
||||
src = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
assert "let _browserTtsKeepAlive=null;" in src
|
||||
assert "let _browserTtsWatchdog=null;" in src
|
||||
assert "let _browserTtsSuppressNextErrorRearm=false;" in src
|
||||
assert "function _clearBrowserTtsRecovery()" in src
|
||||
assert "function _armBrowserTtsRecovery(clean, rate)" in src
|
||||
|
||||
|
||||
def test_browser_tts_watchdog_rearms_listening_if_onend_drops():
|
||||
src = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
arm_body = _extract_function(src, "_armBrowserTtsRecovery")
|
||||
assert "_browserTtsWatchdog=setTimeout" in arm_body
|
||||
assert "_voiceModeState!=='speaking'" in arm_body
|
||||
assert "_browserTtsSuppressNextErrorRearm=true;" in arm_body
|
||||
assert "speechSynthesis.cancel()" in arm_body
|
||||
assert "_startListening();" in arm_body
|
||||
assert "_browserTtsKeepAlive=setInterval" in arm_body
|
||||
assert "speechSynthesis.pause();" in arm_body
|
||||
assert "speechSynthesis.resume();" in arm_body
|
||||
|
||||
|
||||
def test_browser_tts_callbacks_and_deactivate_clear_recovery_handles():
|
||||
src = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
speak_body = _extract_function(src, "_speakResponse")
|
||||
assert "const utter=new SpeechSynthesisUtterance(clean);" in speak_body
|
||||
assert "utter.onend=()=>{" in speak_body
|
||||
assert "utter.onerror=()=>{" in speak_body
|
||||
assert speak_body.count("_clearBrowserTtsRecovery();") >= 2, (
|
||||
"Both browser TTS completion callbacks must clear watchdog/keep-alive handles."
|
||||
)
|
||||
assert "_browserTtsSuppressNextErrorRearm=false;" in speak_body
|
||||
assert "_voiceModeActive&&_voiceModeState==='speaking'" in speak_body
|
||||
assert "if(_browserTtsSuppressNextErrorRearm){" in speak_body
|
||||
assert "_armBrowserTtsRecovery(clean, utter.rate);" in speak_body
|
||||
|
||||
deactivate_body = _extract_function(src, "_deactivate")
|
||||
assert "_clearBrowserTtsRecovery();" in deactivate_body, (
|
||||
"_deactivate() must clear browser TTS watchdog/keep-alive handles."
|
||||
)
|
||||
assert "_browserTtsSuppressNextErrorRearm=false;" in deactivate_body
|
||||
|
||||
|
||||
def test_edge_audio_branch_stays_separate():
|
||||
src = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
edge_match = re.search(
|
||||
r'if\(engine==="edge"\)\{(.*?)\n\s+return;\n\s+\}',
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert edge_match, "Edge audio branch must exist"
|
||||
edge_body = edge_match.group(1)
|
||||
assert "const audio = new Audio(url);" in edge_body
|
||||
assert "audio.onended = () => {" in edge_body
|
||||
assert "_armBrowserTtsRecovery" not in edge_body, (
|
||||
"The browser speechSynthesis workaround must not be injected into the Edge audio branch."
|
||||
)
|
||||
@@ -13,6 +13,7 @@ Covers:
|
||||
4. switch_profile(process_wide=False) does NOT mutate process globals
|
||||
5. Concurrent requests on different threads see independent profiles
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -230,6 +231,60 @@ class TestProfileCookieHelpers:
|
||||
assert get_profile_cookie(handler) is None
|
||||
|
||||
|
||||
# ── 1b. Profile cookie name resolution (env > legacy env > default) ───────────
|
||||
|
||||
class TestProfileCookieNameResolution:
|
||||
|
||||
def test_default_when_unset(self, monkeypatch):
|
||||
from api.helpers import PROFILE_COOKIE_NAME, get_profile_cookie_name
|
||||
monkeypatch.delenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', raising=False)
|
||||
monkeypatch.delenv('WEBUI_PROFILE_COOKIE_NAME', raising=False)
|
||||
assert get_profile_cookie_name() == PROFILE_COOKIE_NAME
|
||||
|
||||
def test_canonical_env_overrides_default(self, monkeypatch):
|
||||
from api.helpers import get_profile_cookie_name
|
||||
monkeypatch.delenv('WEBUI_PROFILE_COOKIE_NAME', raising=False)
|
||||
monkeypatch.setenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_alt')
|
||||
assert get_profile_cookie_name() == 'hermes_profile_alt'
|
||||
|
||||
def test_legacy_env_still_honoured(self, monkeypatch):
|
||||
from api.helpers import get_profile_cookie_name
|
||||
monkeypatch.delenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', raising=False)
|
||||
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_legacy')
|
||||
assert get_profile_cookie_name() == 'hermes_profile_legacy'
|
||||
|
||||
def test_canonical_takes_precedence_over_legacy(self, monkeypatch):
|
||||
from api.helpers import get_profile_cookie_name
|
||||
monkeypatch.setenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', 'canonical')
|
||||
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'legacy')
|
||||
assert get_profile_cookie_name() == 'canonical'
|
||||
|
||||
def test_blank_canonical_falls_back_to_legacy(self, monkeypatch):
|
||||
from api.helpers import get_profile_cookie_name
|
||||
monkeypatch.setenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', ' ')
|
||||
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_legacy')
|
||||
assert get_profile_cookie_name() == 'hermes_profile_legacy'
|
||||
|
||||
def test_blank_envs_fall_back_to_default(self, monkeypatch):
|
||||
from api.helpers import PROFILE_COOKIE_NAME, get_profile_cookie_name
|
||||
monkeypatch.setenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', ' ')
|
||||
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', '')
|
||||
assert get_profile_cookie_name() == PROFILE_COOKIE_NAME
|
||||
|
||||
def test_legacy_deprecation_warns_only_once(self, monkeypatch, caplog):
|
||||
# get_profile_cookie_name() runs on every request, so the deprecation
|
||||
# warning for the legacy env var must be emitted once per process.
|
||||
import api.helpers as helpers
|
||||
monkeypatch.delenv('HERMES_WEBUI_PROFILE_COOKIE_NAME', raising=False)
|
||||
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_legacy')
|
||||
monkeypatch.setattr(helpers, '_legacy_profile_cookie_warned', False)
|
||||
with caplog.at_level(logging.WARNING, logger='api.helpers'):
|
||||
for _ in range(3):
|
||||
assert helpers.get_profile_cookie_name() == 'hermes_profile_legacy'
|
||||
warned = [r for r in caplog.records if 'deprecated' in r.getMessage()]
|
||||
assert len(warned) == 1
|
||||
|
||||
|
||||
# ── 2. Thread-local request context ──────────────────────────────────────────
|
||||
|
||||
class TestThreadLocalProfileContext:
|
||||
|
||||
Reference in New Issue
Block a user