mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
0e9bd651a4
Two unrelated UX/Settings bugs, both small surgical fixes with regression tests. Issue #1409 — TTS toggle has no effect ======================================= Reported via Discord: ticking Settings → Voice → "Text-to-Speech for responses" did nothing. The speaker icon never appeared on assistant messages despite the checkbox saving to localStorage correctly. Root cause (CSS specificity collision): static/panels.js _applyTtsEnabled() set btn.style.display = enabled ? '' : 'none' on every .msg-tts-btn. The '' branch removes the inline override, after which the .msg-tts-btn { display:none; } rule from style.css re-hides the button. Both branches left the icon hidden, so the toggle has been silently broken since #499 first shipped the TTS feature. Fix (body-class toggle, Option B from the issue): - panels.js: _applyTtsEnabled now toggles body.classList('tts-enabled') - style.css: new compound selector body.tts-enabled .msg-tts-btn { display:inline-flex; align-items:center; } - default-hidden rule (.msg-tts-btn{display:none;}) preserved so the icon stays hidden by default (CSS-only state) - boot.js paths that already call _applyTtsEnabled(localStorage…) work unchanged — the new function applies state at the body level instead of inline-styling individual buttons, so the rule survives renderMd() re-renders without re-querying every button Verified end-to-end against live server: getComputedStyle on a probe .msg-tts-btn returns display:flex when body has tts-enabled, display:none when it doesn't. Two regression tests in TestIssue1409TtsToggleBodyClass explicitly check for the body-class shape and forbid the broken inline-style pattern. Issue #1410 — Ollama (local) shows "API key configured" when only Ollama Cloud key is set ================================================================= Reported via Discord: configuring Ollama Cloud lit up the local Ollama card too. Both providers were mapped to OLLAMA_API_KEY in api/providers.py _PROVIDER_ENV_VAR. Root cause: api/providers.py:47-48 "ollama": "OLLAMA_API_KEY", "ollama-cloud": "OLLAMA_API_KEY", _provider_has_key("ollama") found the value the user set for Ollama Cloud and returned True. But the runtime code path in hermes_cli/runtime_provider.py only consumes OLLAMA_API_KEY when the base URL hostname is ollama.com (Ollama Cloud) — local Ollama is keyless by default and reaches a custom base URL with no auth. The WebUI was reporting "configured" for a key local Ollama doesn't even read. Fix (Option A from the issue body, preferred): - Drop bare "ollama" from _PROVIDER_ENV_VAR with an inline comment explaining why - _provider_has_key("ollama") falls through to the config.yaml branch, which already supports providers.ollama.api_key for local users who genuinely need to set a token - ollama-cloud retains its OLLAMA_API_KEY mapping unchanged Verified end-to-end against live server with OLLAMA_API_KEY=sk-cloud-key-test in env: GET /api/providers reports has_key=True only for ollama-cloud, and has_key=False for bare ollama. Two regression tests in TestIssue1410OllamaEnvVarBleed cover the bleed-prevention case AND the "local user with config.yaml api_key still reports configured" case to guard against over-correction. Tests ----- 3572 passed, 2 skipped, 3 xpassed (was 3567 — added 5 new regression tests). Closes #1409 Closes #1410 Reported by @AvidFuturist (Discord, May 1 2026)
250 lines
9.6 KiB
Python
250 lines
9.6 KiB
Python
"""
|
|
Tests for #499: TTS playback of agent responses via Web Speech API.
|
|
|
|
Verifies that TTS utility functions, speaker button rendering, and
|
|
settings controls are present in the WebUI codebase.
|
|
"""
|
|
import os
|
|
import re
|
|
|
|
STATIC_DIR = os.path.join(os.path.dirname(__file__), '..', 'static')
|
|
|
|
|
|
def _read(filename):
|
|
return open(os.path.join(STATIC_DIR, filename), encoding='utf-8').read()
|
|
|
|
|
|
class TestTtsUtilityFunctions:
|
|
"""TTS core functions exist in ui.js."""
|
|
|
|
def test_strip_for_tts_exists(self):
|
|
src = _read('ui.js')
|
|
assert 'function _stripForTTS(' in src, \
|
|
"_stripForTTS function not found in ui.js"
|
|
|
|
def test_speak_message_exists(self):
|
|
src = _read('ui.js')
|
|
assert 'function speakMessage(' in src, \
|
|
"speakMessage function not found in ui.js"
|
|
|
|
def test_stop_tts_exists(self):
|
|
src = _read('ui.js')
|
|
assert 'function stopTTS(' in src, \
|
|
"stopTTS function not found in ui.js"
|
|
|
|
def test_auto_read_exists(self):
|
|
src = _read('ui.js')
|
|
assert 'function autoReadLastAssistant(' in src, \
|
|
"autoReadLastAssistant function not found in ui.js"
|
|
|
|
def test_strip_code_blocks(self):
|
|
"""_stripForTTS must remove ``` code blocks."""
|
|
src = _read('ui.js')
|
|
assert re.search(r'_stripForTTS.*```', src, re.DOTALL), \
|
|
"_stripForTTS must handle fenced code blocks"
|
|
|
|
def test_strip_media_paths(self):
|
|
"""_stripForTTS must replace MEDIA: paths."""
|
|
src = _read('ui.js')
|
|
assert 'MEDIA:' in src and 'a file' in src, \
|
|
"_stripForTTS must replace MEDIA: paths"
|
|
|
|
def test_uses_speech_synthesis(self):
|
|
"""speakMessage must use window.speechSynthesis."""
|
|
src = _read('ui.js')
|
|
assert 'SpeechSynthesisUtterance' in src, \
|
|
"speakMessage must create SpeechSynthesisUtterance"
|
|
assert 'speechSynthesis.speak' in src, \
|
|
"speakMessage must call speechSynthesis.speak"
|
|
|
|
|
|
class TestTtsSpeakerButton:
|
|
"""Speaker button is rendered on assistant messages."""
|
|
|
|
def test_tts_button_rendered(self):
|
|
"""ttsBtn must be generated for non-user messages."""
|
|
src = _read('ui.js')
|
|
assert 'msg-tts-btn' in src, \
|
|
"TTS button class not found in ui.js"
|
|
|
|
def test_tts_button_not_on_user_messages(self):
|
|
"""ttsBtn must only be added for non-user (assistant) messages."""
|
|
src = _read('ui.js')
|
|
# Find the ttsBtn definition — it should have !isUser guard
|
|
tts_line = [l for l in src.splitlines() if 'msg-tts-btn' in l][0]
|
|
assert '!isUser' in tts_line or 'isUser' in tts_line, \
|
|
"TTS button should have user-check guard"
|
|
|
|
def test_tts_button_in_footer(self):
|
|
"""ttsBtn must be included in the msg-actions span."""
|
|
src = _read('ui.js')
|
|
# The footHtml line should include ttsBtn
|
|
foot_lines = [l for l in src.splitlines() if 'footHtml' in l and 'msg-actions' in l]
|
|
assert any('ttsBtn' in l for l in foot_lines), \
|
|
"ttsBtn not included in footHtml msg-actions"
|
|
|
|
def test_tts_button_uses_volume_icon(self):
|
|
"""Speaker button should use volume-2 icon."""
|
|
src = _read('ui.js')
|
|
tts_line = [l for l in src.splitlines() if 'msg-tts-btn' in l][0]
|
|
assert 'volume-2' in tts_line, \
|
|
"TTS button should use volume-2 icon"
|
|
|
|
|
|
class TestTtsSettings:
|
|
"""TTS settings controls exist in the HTML and are wired in panels.js."""
|
|
|
|
def test_tts_enabled_checkbox(self):
|
|
src = _read('index.html')
|
|
assert 'settingsTtsEnabled' in src, \
|
|
"TTS enabled checkbox not found in index.html"
|
|
|
|
def test_tts_auto_read_checkbox(self):
|
|
src = _read('index.html')
|
|
assert 'settingsTtsAutoRead' in src, \
|
|
"TTS auto-read checkbox not found in index.html"
|
|
|
|
def test_tts_voice_selector(self):
|
|
src = _read('index.html')
|
|
assert 'settingsTtsVoice' in src, \
|
|
"TTS voice selector not found in index.html"
|
|
|
|
def test_tts_rate_slider(self):
|
|
src = _read('index.html')
|
|
assert 'settingsTtsRate' in src, \
|
|
"TTS rate slider not found in index.html"
|
|
|
|
def test_tts_pitch_slider(self):
|
|
src = _read('index.html')
|
|
assert 'settingsTtsPitch' in src, \
|
|
"TTS pitch slider not found in index.html"
|
|
|
|
def test_tts_settings_wired_in_panels(self):
|
|
"""TTS settings must be initialized in loadSettingsPanel."""
|
|
src = _read('panels.js')
|
|
assert 'settingsTtsEnabled' in src, \
|
|
"TTS enabled setting not wired in panels.js"
|
|
assert '_applyTtsEnabled' in src, \
|
|
"_applyTtsEnabled not called in panels.js"
|
|
|
|
def test_apply_tts_enabled_function(self):
|
|
"""_applyTtsEnabled must toggle msg-tts-btn display."""
|
|
src = _read('panels.js')
|
|
assert 'function _applyTtsEnabled(' in src, \
|
|
"_applyTtsEnabled function not found in panels.js"
|
|
|
|
|
|
class TestTtsI18n:
|
|
"""TTS i18n keys exist in the English locale."""
|
|
|
|
def test_tts_listen_key(self):
|
|
src = _read('i18n.js')
|
|
assert "tts_listen:" in src, \
|
|
"tts_listen key not found in i18n.js"
|
|
|
|
def test_tts_not_supported_key(self):
|
|
src = _read('i18n.js')
|
|
assert "tts_not_supported:" in src, \
|
|
"tts_not_supported key not found in i18n.js"
|
|
|
|
def test_tts_settings_keys(self):
|
|
src = _read('i18n.js')
|
|
for key in ['settings_label_tts', 'settings_label_tts_auto_read',
|
|
'settings_label_tts_voice', 'settings_label_tts_rate',
|
|
'settings_label_tts_pitch']:
|
|
assert f"{key}:" in src, f"{key} not found in i18n.js"
|
|
|
|
|
|
class TestTtsAutoRead:
|
|
"""Auto-read is triggered after SSE done event."""
|
|
|
|
def test_auto_read_called_in_messages(self):
|
|
src = _read('messages.js')
|
|
assert 'autoReadLastAssistant' in src, \
|
|
"autoReadLastAssistant not called in messages.js"
|
|
|
|
def test_tts_pause_on_composer_focus(self):
|
|
"""Speech should pause when user focuses the composer."""
|
|
src = _read('messages.js')
|
|
assert 'speechSynthesis.pause' in src, \
|
|
"speechSynthesis.pause not called in messages.js"
|
|
assert 'speechSynthesis.resume' in src, \
|
|
"speechSynthesis.resume not called in messages.js"
|
|
|
|
|
|
class TestTtsBoot:
|
|
"""TTS enabled state is applied on page load."""
|
|
|
|
def test_apply_tts_on_boot(self):
|
|
src = _read('boot.js')
|
|
assert '_applyTtsEnabled' in src, \
|
|
"_applyTtsEnabled not called in boot.js"
|
|
|
|
|
|
class TestTtsStyles:
|
|
"""TTS CSS styles exist."""
|
|
|
|
def test_tts_button_hidden_default(self):
|
|
src = _read('style.css')
|
|
assert '.msg-tts-btn' in src, \
|
|
".msg-tts-btn CSS class not found in style.css"
|
|
|
|
def test_tts_pulse_animation(self):
|
|
src = _read('style.css')
|
|
assert 'tts-pulse' in src, \
|
|
"tts-pulse animation not found in style.css"
|
|
|
|
|
|
class TestIssue1409TtsToggleBodyClass:
|
|
"""Regression: #1409 — TTS toggle had no effect because of CSS specificity collision.
|
|
|
|
Original bug: ``_applyTtsEnabled`` set ``btn.style.display=enabled?'':'none'``.
|
|
The empty-string branch removes the inline override, after which the
|
|
``.msg-tts-btn { display:none; }`` rule from style.css applies — so both
|
|
"enabled" and "disabled" states left the button hidden.
|
|
|
|
Fix: toggle a body-level class (``body.tts-enabled``) and gate the speaker
|
|
icon on a compound selector ``body.tts-enabled .msg-tts-btn``. This bypasses
|
|
the inline-style cascade collision and survives ``renderMd()`` re-renders.
|
|
"""
|
|
|
|
def test_apply_tts_enabled_uses_body_class(self):
|
|
"""_applyTtsEnabled must toggle the document body's `tts-enabled` class."""
|
|
src = _read('panels.js')
|
|
# The new shape: toggle body class instead of writing inline display
|
|
assert "document.body.classList.toggle('tts-enabled'" in src, (
|
|
"_applyTtsEnabled must toggle the body.tts-enabled class — see #1409. "
|
|
"Reverting to inline `style.display` will silently break the toggle "
|
|
"again because of the .msg-action-btn / .msg-tts-btn cascade."
|
|
)
|
|
|
|
def test_apply_tts_enabled_does_not_use_inline_display(self):
|
|
"""_applyTtsEnabled must NOT set inline `style.display` on .msg-tts-btn."""
|
|
src = _read('panels.js')
|
|
# Find the function body and check it doesn't set inline display
|
|
# on individual buttons (the broken pattern).
|
|
m = re.search(
|
|
r'function _applyTtsEnabled\([^)]*\)\s*\{(?P<body>[^}]*)\}',
|
|
src,
|
|
)
|
|
assert m, "_applyTtsEnabled function body not found in panels.js"
|
|
body = m.group('body')
|
|
assert '.style.display' not in body, (
|
|
"_applyTtsEnabled body must not set inline style.display — that's "
|
|
"the #1409 bug. Use body.classList.toggle('tts-enabled') instead."
|
|
)
|
|
|
|
def test_body_class_selector_in_css(self):
|
|
"""style.css must show .msg-tts-btn only when body.tts-enabled is set."""
|
|
src = _read('style.css')
|
|
assert 'body.tts-enabled .msg-tts-btn' in src, (
|
|
"Missing `body.tts-enabled .msg-tts-btn` selector in style.css — "
|
|
"without this rule the body class has no visual effect (#1409)."
|
|
)
|
|
# The default-hidden rule must still be present (so no body class = no icon).
|
|
assert '.msg-tts-btn{display:none;}' in src or \
|
|
re.search(r'\.msg-tts-btn\s*\{[^}]*display\s*:\s*none', src), (
|
|
"Default `.msg-tts-btn{display:none;}` rule must remain so the "
|
|
"icon is hidden by default (#1409)."
|
|
)
|