Merge remote-tracking branch 'origin/master' into release/stage-p1c

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes
2026-07-03 20:44:31 +00:00
7 changed files with 837 additions and 107 deletions
+1
View File
@@ -15,6 +15,7 @@
- **The sidebar no longer keeps polling and rebuilding itself in a hidden background tab.** The 30-second session-list poll and the 60-second relative-time refresh ran unconditionally, so a backgrounded window kept fetching `/api/sessions` and rebuilding a sidebar it couldn't show. Both now pause while the tab is hidden and refresh immediately when you return to it (each poll has its own `visibilitychange` handler), so nothing goes stale — you just stop paying for updates you can't see. (One small timing change: while a tab is hidden *and* a session is streaming, the in-app attention chime / unread badge now fires when you return to the tab rather than mid-hidden; OS-level notifications are unaffected, and browsers already throttled the old hidden-tab poll anyway.) Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5465, #5455)
- **The sidebar session list stops holding a write-capable database handle just to read.** Building the sidebar opened a read-write SQLite connection on the live (potentially multi-GB, WAL-mode) state database and re-ran a defensive index check on every refresh — needless lock/checkpoint contention while the agent is streaming into the same database. The listing now opens the database read-only, and only touches it with a separate short-lived writable connection on the rare occasion the index is actually missing. Output is unchanged. Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5464, #5455)
- **Your text-to-speech and voice-mode preferences now follow you across devices and browsers.** TTS settings (enabled, auto-read, engine, voice, rate, pitch) and voice-mode settings (voice button, raw-audio mode, continuous listening, silence timeout) were stored only in the browser's localStorage, so they were lost on a new device, a cleared browser, or a fresh profile — and never matched what the server knew. They're now persisted server-side in your settings file and mirrored back to the client. Only preferences you've actually set are written (a never-configured key stays absent and falls back to its default rather than being pinned to a value), and no save path clobbers your other settings. The voice-mode silence timeout is read at the moment auto-send is scheduled, so a persisted value takes effect on first load without a reload. Thanks @rodboev. (#5442, #5435)
- **A profile whose default model lives in a large provider's picker "overflow" list no longer fails validation.** When a provider has more models than the visible picker cap (15 entries), the extras are moved into an overflow bucket that still renders in the dropdown. Profile model validation only searched the visible list, so selecting one of those overflow models as a profile default was rejected with a spurious "not available for provider" error even though it appeared (and worked) in the picker. Validation now searches both the visible and overflow buckets, matching what the dropdown shows. Thanks @WallaceWebster. (#5453)
- **Read-only sessions (cron runs, subagent views) no longer show a broken "fork from here" affordance.** Forking is only meaningful for sessions you own, but the fork/branch controls were shown on read-only cron and subagent sessions too — clicking them just produced a confusing "Session not found" error. Those controls are now hidden for read-only sessions on the frontend, and the branch endpoint refuses a read-only source with a clear `403` instead of a misleading `404`. Thanks @rodboev. (#5449, #5439)
- **Inline Mermaid diagrams stay readable instead of collapsing to a sliver.** The inline diagram viewer shared the lightbox's fit-to-screen scale, so on mobile (and other short viewports) a diagram could shrink to an unreadable strip. Inline rendering now uses its own width-based sizing with a minimum height floor (and its own minimum scale), decoupled from the full-screen lightbox fit, so inline diagrams keep a usable size while the lightbox zoom/pan behavior is unchanged. Thanks @rodboev. (#5434, #5413)
+165 -68
View File
@@ -8007,6 +8007,16 @@ _SETTINGS_DEFAULTS = {
"check_for_updates": True, # check if webui/agent repos are behind upstream
"ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks
"whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links
"tts_enabled": False,
"tts_auto_read": False,
"tts_engine": "browser",
"tts_voice": "",
"tts_rate": 1.0,
"tts_pitch": 1.0,
"voice_mode_button": False,
"voice_continuous": False,
"voice_silence_ms": 1800,
"raw_audio_mode": False,
"theme": "dark", # light | dark | system
"skin": "default", # accent color skin: default | ares | mono | graphite | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous
"font_size": "default", # small | default | large | xlarge
@@ -8065,6 +8075,19 @@ _SETTINGS_DEFAULTS = {
"auth_disabled_acknowledged": False, # user acknowledged unauthenticated risk
"provider_cost_budget": None,
}
_SETTINGS_SPEECH_KEYS = {
"tts_enabled",
"tts_auto_read",
"tts_engine",
"tts_voice",
"tts_rate",
"tts_pitch",
"voice_mode_button",
"voice_continuous",
"voice_silence_ms",
"raw_audio_mode",
}
_SETTINGS_PERSISTED_SPEECH_KEYS_FIELD = "persisted_speech_keys"
_SETTINGS_LEGACY_DROP_KEYS = {
"assistant_language",
"bubble_layout",
@@ -8142,75 +8165,104 @@ def _normalize_appearance(theme, skin) -> tuple[str, str]:
return next_theme, next_skin
def _read_raw_settings_file() -> dict:
"""Read settings.json without applying defaults."""
try:
if not SETTINGS_FILE.exists():
return {}
except OSError:
# PermissionError or other OS-level error (e.g. UID mismatch in Docker)
# Treat as missing rather than failing startup.
logger.debug("Cannot stat settings file %s (inaccessible?)", SETTINGS_FILE)
return {}
try:
loaded = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
logger.debug("Failed to load settings from %s", SETTINGS_FILE)
return {}
return loaded if isinstance(loaded, dict) else {}
def _extract_persisted_speech_keys(stored: dict) -> set[str]:
if not isinstance(stored, dict):
return set()
return {key for key in _SETTINGS_SPEECH_KEYS if key in stored}
def persisted_speech_settings_keys() -> list[str]:
return sorted(_extract_persisted_speech_keys(_read_raw_settings_file()))
def _settings_payload_for_write(settings: dict, persisted_speech_keys: set[str]) -> dict:
persisted = {
k: v
for k, v in settings.items()
if k not in {"default_model", _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD}
}
for speech_key in _SETTINGS_SPEECH_KEYS:
if speech_key not in persisted_speech_keys:
persisted.pop(speech_key, None)
return persisted
def load_settings() -> dict:
"""Load settings from disk, merging with defaults for any missing keys."""
settings = dict(_SETTINGS_DEFAULTS)
stored = None
try:
settings_exists = SETTINGS_FILE.exists()
except OSError:
# PermissionError or other OS-level error (e.g. UID mismatch in Docker)
# Treat as missing — start with defaults rather than crashing.
logger.debug("Cannot stat settings file %s (inaccessible?)", SETTINGS_FILE)
settings_exists = False
if settings_exists:
try:
stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
if isinstance(stored, dict):
if (
"worklog_details_expanded_default" not in stored
and "activity_feed_expanded_default" in stored
):
settings["worklog_details_expanded_default"] = bool(
stored.get("activity_feed_expanded_default")
)
settings.update(
{
k: v
for k, v in stored.items()
if k not in _SETTINGS_LEGACY_DROP_KEYS
}
)
if (
"default_message_mode" not in stored
and "busy_input_mode" in stored
):
settings["default_message_mode"] = stored.get("busy_input_mode")
settings.pop("busy_input_mode", None)
# Grandfather established installs OFF for show_cli_sessions (#3988).
# The default flipped True so NEW users see CLI/TUI/messaging
# sessions without hunting for the toggle — but an existing user
# who never opted in should not have their sidebar silently change.
# Treat the install as established (and pin the old False default)
# when show_cli_sessions is absent AND the file already carries
# real user state — either onboarding was completed, or some
# setting OTHER than a not-yet-completed onboarding flag has been
# persisted. Keying on "has saved user state" (not just
# onboarding_completed) also covers a CLI-configured user who
# tweaked a WebUI setting before running the wizard. A genuinely
# new / still-mid-onboarding file falls through to the True default.
_established_keys = [
k for k in stored
if k not in ("show_cli_sessions", "onboarding_completed")
]
if "show_cli_sessions" not in stored and (
bool(stored.get("onboarding_completed")) or _established_keys
):
settings["show_cli_sessions"] = False
# Force-off-for-everyone migration for virtualize_transcript (#4343).
# The feature shipped opt-OUT/default-on in #4325, then proved to
# cause scroll-up flicker on long sessions (variable-height anchor
# oscillation). It is now EXPERIMENTAL/opt-IN (default off). Any
# stored virtualize_transcript=True from the #4325 window is a stale
# pre-flip value and must be reset to off, so 100% of existing users
# land on off — re-enabling requires an explicit opt-in made AFTER
# the flip, which writes virtualize_transcript_optin=True alongside.
# Honor a stored True only when that marker is present.
if not bool(stored.get("virtualize_transcript_optin")):
settings["virtualize_transcript"] = False
except Exception:
logger.debug("Failed to load settings from %s", SETTINGS_FILE)
stored = _read_raw_settings_file()
if isinstance(stored, dict):
if (
"worklog_details_expanded_default" not in stored
and "activity_feed_expanded_default" in stored
):
settings["worklog_details_expanded_default"] = bool(
stored.get("activity_feed_expanded_default")
)
settings.update(
{
k: v
for k, v in stored.items()
if k not in _SETTINGS_LEGACY_DROP_KEYS
and k != _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD
}
)
if (
"default_message_mode" not in stored
and "busy_input_mode" in stored
):
settings["default_message_mode"] = stored.get("busy_input_mode")
settings.pop("busy_input_mode", None)
# Grandfather established installs OFF for show_cli_sessions (#3988).
# The default flipped True so NEW users see CLI/TUI/messaging
# sessions without hunting for the toggle — but an existing user
# who never opted in should not have their sidebar silently change.
# Treat the install as established (and pin the old False default)
# when show_cli_sessions is absent AND the file already carries
# real user state — either onboarding was completed, or some
# setting OTHER than a not-yet-completed onboarding flag has been
# persisted. Keying on "has saved user state" (not just
# onboarding_completed) also covers a CLI-configured user who
# tweaked a WebUI setting before running the wizard. A genuinely
# new / still-mid-onboarding file falls through to the True default.
_established_keys = [
k for k in stored
if k not in ("show_cli_sessions", "onboarding_completed")
]
if "show_cli_sessions" not in stored and (
bool(stored.get("onboarding_completed")) or _established_keys
):
settings["show_cli_sessions"] = False
# Force-off-for-everyone migration for virtualize_transcript (#4343).
# The feature shipped opt-OUT/default-on in #4325, then proved to
# cause scroll-up flicker on long sessions (variable-height anchor
# oscillation). It is now EXPERIMENTAL/opt-IN (default off). Any
# stored virtualize_transcript=True from the #4325 window is a stale
# pre-flip value and must be reset to off, so 100% of existing users
# land on off — re-enabling requires an explicit opt-in made AFTER
# the flip, which writes virtualize_transcript_optin=True alongside.
# Honor a stored True only when that marker is present.
if not bool(stored.get("virtualize_transcript_optin")):
settings["virtualize_transcript"] = False
settings["theme"], settings["skin"] = _normalize_appearance(
stored.get("theme") if isinstance(stored, dict) else settings.get("theme"),
stored.get("skin") if isinstance(stored, dict) else settings.get("skin"),
@@ -8247,6 +8299,11 @@ _SETTINGS_INT_RANGES = {
"inflight_state_max_string_chars": (1000, 500000),
"inflight_state_max_json_chars": (100000, 4000000),
"structured_code_auto_tree_lines": (1, 1000),
"voice_silence_ms": (200, 60000),
}
_SETTINGS_FLOAT_RANGES = {
"tts_rate": (0.5, 2.0),
"tts_pitch": (0.0, 2.0),
}
_SETTINGS_BOOL_KEYS = {
"onboarding_completed",
@@ -8268,6 +8325,11 @@ _SETTINGS_BOOL_KEYS = {
"check_for_updates",
"ignore_agent_updates",
"whats_new_summary_enabled",
"tts_enabled",
"tts_auto_read",
"voice_mode_button",
"voice_continuous",
"raw_audio_mode",
"sound_enabled",
"rtl",
"notifications_enabled",
@@ -8302,6 +8364,7 @@ _SETTINGS_BOOL_KEYS = {
}
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
_SETTINGS_TTS_ENGINE_RE = __import__("re").compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
_SETTINGS_WRITE_VERSION = 0
_SETTINGS_WRITE_LOCK = __import__("threading").Lock()
@@ -8320,7 +8383,10 @@ def _coerce_provider_cost_budget(value: Any) -> float | None:
def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
raw_settings = _read_raw_settings_file()
persisted_speech_keys = _extract_persisted_speech_keys(raw_settings)
current = load_settings()
applied_speech_keys: set[str] = set()
if (
"worklog_details_expanded_default" not in settings
and "activity_feed_expanded_default" in settings
@@ -8363,6 +8429,7 @@ def save_settings(settings: dict) -> dict:
current_dash.update({k: bool(v) for k, v in _dashboard_plugins.items() if isinstance(k, str)})
current["dashboard_plugins"] = current_dash
for k, v in settings.items():
key_is_speech = k in _SETTINGS_SPEECH_KEYS
# dashboard_plugins is deep-merged above (not a flat allowlisted scalar).
if k == "dashboard_plugins":
continue
@@ -8389,6 +8456,23 @@ def save_settings(settings: dict) -> dict:
min_value, max_value = _SETTINGS_INT_RANGES[k]
if v < min_value or v > max_value:
continue
if k in _SETTINGS_FLOAT_RANGES:
try:
v = float(v)
except (TypeError, ValueError):
continue
min_value, max_value = _SETTINGS_FLOAT_RANGES[k]
if not math.isfinite(v) or v < min_value or v > max_value:
continue
if k == "tts_engine":
if not isinstance(v, str):
continue
v = v.strip()
if not _SETTINGS_TTS_ENGINE_RE.match(v):
continue
if k == "tts_voice":
if not isinstance(v, str) or len(v) > 200 or "\x00" in v:
continue
# Validate language codes (BCP-47-like: 'en', 'zh', 'fr', 'zh-CN')
if k == "language" and (
not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)
@@ -8428,6 +8512,8 @@ def save_settings(settings: dict) -> dict:
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)
current[k] = v
if key_is_speech:
applied_speech_keys.add(k)
theme_value = pending_theme
skin_value = pending_skin
if theme_was_explicit and not skin_was_explicit:
@@ -8439,7 +8525,9 @@ def save_settings(settings: dict) -> dict:
current["default_workspace"] = str(
resolve_default_workspace(current.get("default_workspace"))
)
persisted = {k: v for k, v in current.items() if k != "default_model"}
effective_persisted_speech_keys = set(persisted_speech_keys)
effective_persisted_speech_keys.update(applied_speech_keys)
persisted = _settings_payload_for_write(current, effective_persisted_speech_keys)
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_FILE.write_text(
json.dumps(persisted, ensure_ascii=False, indent=2),
@@ -8481,8 +8569,17 @@ if _settings_file_exists:
if _startup_settings.get("default_workspace") != str(DEFAULT_WORKSPACE):
_startup_settings["default_workspace"] = str(DEFAULT_WORKSPACE)
try:
startup_persisted_speech_keys = _extract_persisted_speech_keys(
_read_raw_settings_file()
)
SETTINGS_FILE.write_text(
json.dumps(_startup_settings, ensure_ascii=False, indent=2),
json.dumps(
_settings_payload_for_write(
_startup_settings, startup_persisted_speech_keys
),
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
except Exception:
+3
View File
@@ -2793,6 +2793,7 @@ from api.config import (
SESSION_AGENT_LOCKS_LOCK,
CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS,
load_settings,
persisted_speech_settings_keys,
save_settings,
SETTINGS_FILE,
set_hermes_default_model,
@@ -11446,6 +11447,7 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/settings":
settings = load_settings()
settings["persisted_speech_keys"] = persisted_speech_settings_keys()
# Never expose the stored password hash to clients
settings.pop("password_hash", None)
settings.setdefault("max_tokens", None)
@@ -14321,6 +14323,7 @@ def handle_post(handler, parsed) -> bool:
from api.config import get_max_tokens_status, set_max_tokens
saved = save_settings(body)
saved["persisted_speech_keys"] = persisted_speech_settings_keys()
if max_tokens_provided:
max_tokens_status = set_max_tokens(max_tokens_value)
saved.pop("password_hash", None) # never expose hash to client
+90 -10
View File
@@ -679,6 +679,15 @@ function _micToastKeyForRecognitionError(error){
}
}
function _applyRawAudioModePreference(enabled){
_rawAudioMode=!!enabled;
try{localStorage.setItem('hermes-raw-audio-mode',_rawAudioMode?'true':'false');}catch(_){}
const rawAudioCheckbox=document.getElementById('settingsRawAudio');
if(rawAudioCheckbox) rawAudioCheckbox.checked=_rawAudioMode;
_updateMicTooltip();
}
window._applyRawAudioModePreference=_applyRawAudioModePreference;
async function _sendRawAudio(blob){
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
const file=new File([blob],`voice-input-${Date.now()}.${ext}`,{type:blob.type||`audio/${ext}`});
@@ -1054,9 +1063,7 @@ function _micToastKeyForRecognitionError(error){
if(rawAudioCheckbox){
rawAudioCheckbox.checked = _rawAudioMode;
rawAudioCheckbox.addEventListener('change', function(){
_rawAudioMode = this.checked;
localStorage.setItem('hermes-raw-audio-mode', _rawAudioMode ? 'true' : 'false');
_updateMicTooltip();
_applyRawAudioModePreference(this.checked);
});
}
_updateMicTooltip();
@@ -1214,12 +1221,12 @@ window._hermesTtsSynth=function(id, text, opts){
let _browserTtsWatchdog=null;
let _browserTtsSuppressNextErrorRearm=false;
// Configurable via localStorage keys (set from dev console or a future settings panel).
// hermes-voice-silence-ms pause duration before auto-send (ms, default 1800)
// hermes-voice-continuous keep mic open across natural pauses ("true"/"false", default false)
const _silenceMsRaw=parseInt(localStorage.getItem('hermes-voice-silence-ms'),10);
// Fall back to 1800 for missing/NaN/non-positive values, and floor at 200ms so a
// mistyped tiny/negative value can't make the recognizer auto-send instantly.
const SILENCE_MS=(Number.isFinite(_silenceMsRaw)&&_silenceMsRaw>0)?Math.max(200,_silenceMsRaw):1800;
// hermes-voice-silence-ms, pause duration before auto-send (ms, default 1800)
// hermes-voice-continuous, keep mic open across natural pauses ("true"/"false", default false)
function _voiceSilenceMs(){
const _silenceMsRaw=parseInt(localStorage.getItem('hermes-voice-silence-ms'),10);
return (Number.isFinite(_silenceMsRaw)&&_silenceMsRaw>0)?Math.max(200,_silenceMsRaw):1800;
}
function _clearBrowserTtsRecovery(){
if(_browserTtsKeepAlive){
@@ -1304,7 +1311,7 @@ window._hermesTtsSynth=function(id, text, opts){
if(_finalText){
_silenceTimer=setTimeout(()=>{
_voiceModeSend();
},SILENCE_MS);
},_voiceSilenceMs());
}
};
@@ -2723,6 +2730,77 @@ function _applyTitlebarProfileVisibility(){
}
window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
function _mirrorSpeechSettingsFromServer(s){
if(!s||typeof s!=='object') return;
const persistedSpeechKeys = new Set(
Array.isArray(s.persisted_speech_keys) ? s.persisted_speech_keys : []
);
const hasServerValue=(settingKey)=>persistedSpeechKeys.has(settingKey);
const defaults={
tts_enabled:false,
tts_auto_read:false,
tts_engine:'browser',
tts_voice:'',
tts_rate:1,
tts_pitch:1,
voice_mode_button:false,
voice_continuous:false,
voice_silence_ms:1800,
raw_audio_mode:false,
};
const cachedValue=(storageKey)=>{
try{return localStorage.getItem(storageKey);}catch(_){return null;}
};
const boolValue=(value)=>value===true||value==='true';
const resolveBool=(settingKey,storageKey)=>{
const server=hasServerValue(settingKey)?s[settingKey]:defaults[settingKey];
const cached=cachedValue(storageKey);
if(!hasServerValue(settingKey)&&cached!==null){
return boolValue(cached);
}
return boolValue(server);
};
const resolveScalar=(settingKey,storageKey)=>{
const server=hasServerValue(settingKey)?s[settingKey]:defaults[settingKey];
const cached=cachedValue(storageKey);
if(!hasServerValue(settingKey)&&cached!==null){
return cached;
}
return server;
};
const boolKeys=[
['tts_enabled','hermes-tts-enabled'],
['tts_auto_read','hermes-tts-auto-read'],
['voice_mode_button','hermes-voice-mode-button'],
['voice_continuous','hermes-voice-continuous'],
];
boolKeys.forEach(([settingKey,storageKey])=>{
if(hasServerValue(settingKey)){
try{localStorage.setItem(storageKey,resolveBool(settingKey,storageKey)?'true':'false');}catch(_){}
}
});
[
['tts_engine','hermes-tts-engine'],
['tts_voice','hermes-tts-voice'],
['tts_rate','hermes-tts-rate'],
['tts_pitch','hermes-tts-pitch'],
['voice_silence_ms','hermes-voice-silence-ms'],
].forEach(([settingKey,storageKey])=>{
if(hasServerValue(settingKey)){
try{localStorage.setItem(storageKey,String(resolveScalar(settingKey,storageKey)));}catch(_){}
}
});
if(hasServerValue('raw_audio_mode')){
const rawAudioMode=resolveBool('raw_audio_mode','hermes-raw-audio-mode');
if(typeof window._applyRawAudioModePreference==='function'){
window._applyRawAudioModePreference(rawAudioMode);
}else{
try{localStorage.setItem('hermes-raw-audio-mode',rawAudioMode?'true':'false');}catch(_){}
}
}
}
window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
(async()=>{
// Load send key preference
let _bootSettings={};
@@ -2872,7 +2950,9 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
_mirrorSpeechSettingsFromServer(s);
_applyComposerFooterVisibilitySettings();
if(typeof window._applyVoiceModePref==='function') window._applyVoiceModePref();
// TTS: apply enabled state on boot so buttons show/hide correctly (#499)
if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
}catch(e){
+126 -14
View File
@@ -7995,6 +7995,49 @@ function _retryAppearanceAutosave(){
// ── Phase 2: Preferences autosave (Issue #1003) ───────────────────────
const _SETTINGS_SPEECH_STORAGE_KEYS={
tts_enabled:'hermes-tts-enabled',
tts_auto_read:'hermes-tts-auto-read',
tts_engine:'hermes-tts-engine',
tts_voice:'hermes-tts-voice',
tts_rate:'hermes-tts-rate',
tts_pitch:'hermes-tts-pitch',
voice_mode_button:'hermes-voice-mode-button',
voice_continuous:'hermes-voice-continuous',
voice_silence_ms:'hermes-voice-silence-ms',
raw_audio_mode:'hermes-raw-audio-mode',
};
let _settingsSpeechPersistedKeys=new Set();
let _settingsSpeechLocalStorageKeys=new Set();
let _settingsSpeechChangedKeys=new Set();
function _captureSpeechPreferenceOwnership(settings){
_settingsSpeechPersistedKeys=new Set(Array.isArray(settings&&settings.persisted_speech_keys)?settings.persisted_speech_keys:[]);
_settingsSpeechLocalStorageKeys=new Set();
_settingsSpeechChangedKeys=new Set();
Object.entries(_SETTINGS_SPEECH_STORAGE_KEYS).forEach(([settingKey,storageKey])=>{
try{if(localStorage.getItem(storageKey)!==null) _settingsSpeechLocalStorageKeys.add(settingKey);}catch(_){}
});
}
function _speechPreferenceIsOwned(settingKey){
return _settingsSpeechPersistedKeys.has(settingKey)||_settingsSpeechLocalStorageKeys.has(settingKey)||_settingsSpeechChangedKeys.has(settingKey);
}
function _markSpeechPreferenceChanged(settingKey){
_settingsSpeechChangedKeys.add(settingKey);
}
function _syncSpeechPreferenceCache(settingKey,value){
if(!_speechPreferenceIsOwned(settingKey)) return;
const storageKey=_SETTINGS_SPEECH_STORAGE_KEYS[settingKey];
if(storageKey) localStorage.setItem(storageKey,String(value));
}
function _setOwnedSpeechPayload(payload,settingKey,value){
if(_speechPreferenceIsOwned(settingKey)) payload[settingKey]=value;
}
function _preferencesPayloadFromUi(){
const payload={};
const sendKeySel=$('settingsSendKey');
@@ -8067,6 +8110,31 @@ function _preferencesPayloadFromUi(){
if(showBusyPlaceholderHintCb) payload.show_busy_placeholder_hint=showBusyPlaceholderHintCb.checked;
const botNameField=$('settingsBotName');
if(botNameField) payload.bot_name=botNameField.value;
Object.assign(payload,_speechPreferencesPayloadFromUi());
return payload;
}
function _speechPreferencesPayloadFromUi(){
const payload={};
const ttsEnabledCb=$('settingsTtsEnabled');
if(ttsEnabledCb) _setOwnedSpeechPayload(payload,'tts_enabled',ttsEnabledCb.checked);
const ttsAutoReadCb=$('settingsTtsAutoRead');
if(ttsAutoReadCb) _setOwnedSpeechPayload(payload,'tts_auto_read',ttsAutoReadCb.checked);
const ttsEngineSel=$('settingsTtsEngine');
if(ttsEngineSel) _setOwnedSpeechPayload(payload,'tts_engine',ttsEngineSel.value||'browser');
const ttsVoiceSel=$('settingsTtsVoice');
if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||'');
const ttsRateSlider=$('settingsTtsRate');
if(ttsRateSlider) _setOwnedSpeechPayload(payload,'tts_rate',parseFloat(ttsRateSlider.value));
const ttsPitchSlider=$('settingsTtsPitch');
if(ttsPitchSlider) _setOwnedSpeechPayload(payload,'tts_pitch',parseFloat(ttsPitchSlider.value));
const voiceModeCb=$('settingsVoiceModeEnabled');
if(voiceModeCb) _setOwnedSpeechPayload(payload,'voice_mode_button',voiceModeCb.checked);
const rawAudioCb=$('settingsRawAudio');
_setOwnedSpeechPayload(payload,'raw_audio_mode',rawAudioCb?rawAudioCb.checked:localStorage.getItem('hermes-raw-audio-mode')==='true');
_setOwnedSpeechPayload(payload,'voice_continuous',localStorage.getItem('hermes-voice-continuous')==='true');
const voiceSilence=parseInt(localStorage.getItem('hermes-voice-silence-ms'),10);
_setOwnedSpeechPayload(payload,'voice_silence_ms',(Number.isFinite(voiceSilence)&&voiceSilence>=200)?voiceSilence:1800);
return payload;
}
@@ -8618,20 +8686,52 @@ async function loadSettingsPanel(){
_schedulePreferencesAutosave();
},{once:false});
}
// TTS settings (localStorage-only, no server round-trip needed)
if(typeof window._mirrorSpeechSettingsFromServer==='function') window._mirrorSpeechSettingsFromServer(settings);
const persistedSpeechKeys = new Set(
Array.isArray(settings && settings.persisted_speech_keys)
? settings.persisted_speech_keys
: []
);
_captureSpeechPreferenceOwnership(settings);
const _speechSetting=function(key,storageKey,fallback,kind){
const stored=localStorage.getItem(storageKey);
if(settings&&persistedSpeechKeys.has(key)) return settings[key];
return stored===null?fallback:stored;
};
const _speechBool=function(key,storageKey,fallback){
const value=_speechSetting(key,storageKey,fallback,'bool');
return value===true||value==='true';
};
const rawAudioCb=$('settingsRawAudio');
if(rawAudioCb){
rawAudioCb.checked=_speechBool('raw_audio_mode','hermes-raw-audio-mode',false);
rawAudioCb.onchange=function(){
_markSpeechPreferenceChanged('raw_audio_mode');
if(typeof window._applyRawAudioModePreference==='function') window._applyRawAudioModePreference(this.checked);
else localStorage.setItem('hermes-raw-audio-mode',this.checked?'true':'false');
_schedulePreferencesAutosave();
};
}
const voiceContinuous=_speechBool('voice_continuous','hermes-voice-continuous',false);
_syncSpeechPreferenceCache('voice_continuous',voiceContinuous?'true':'false');
const voiceSilence=parseInt(_speechSetting('voice_silence_ms','hermes-voice-silence-ms',1800),10);
_syncSpeechPreferenceCache('voice_silence_ms',Number.isFinite(voiceSilence)&&voiceSilence>=200?String(voiceSilence):'1800');
// TTS settings use /api/settings as the durable source and localStorage as the runtime cache.
const ttsEnabledCb=$('settingsTtsEnabled');
if(ttsEnabledCb){ttsEnabledCb.checked=localStorage.getItem('hermes-tts-enabled')==='true';ttsEnabledCb.onchange=function(){localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);};}
if(ttsEnabledCb){ttsEnabledCb.checked=_speechBool('tts_enabled','hermes-tts-enabled',false);ttsEnabledCb.onchange=function(){_markSpeechPreferenceChanged('tts_enabled');localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);_schedulePreferencesAutosave();};}
const ttsAutoReadCb=$('settingsTtsAutoRead');
if(ttsAutoReadCb){ttsAutoReadCb.checked=localStorage.getItem('hermes-tts-auto-read')==='true';ttsAutoReadCb.onchange=function(){localStorage.setItem('hermes-tts-auto-read',this.checked?'true':'false');};}
// Voice-mode button visibility (#1488). localStorage-only; no server round-trip.
if(ttsAutoReadCb){ttsAutoReadCb.checked=_speechBool('tts_auto_read','hermes-tts-auto-read',false);ttsAutoReadCb.onchange=function(){_markSpeechPreferenceChanged('tts_auto_read');localStorage.setItem('hermes-tts-auto-read',this.checked?'true':'false');_schedulePreferencesAutosave();};}
// Voice-mode button visibility (#1488).
// Toggling re-applies immediately via the boot.js helper so the user sees
// the audio-waveform button appear/disappear without a reload.
const voiceModeCb=$('settingsVoiceModeEnabled');
if(voiceModeCb){
voiceModeCb.checked=localStorage.getItem('hermes-voice-mode-button')==='true';
voiceModeCb.checked=_speechBool('voice_mode_button','hermes-voice-mode-button',false);
voiceModeCb.onchange=function(){
_markSpeechPreferenceChanged('voice_mode_button');
localStorage.setItem('hermes-voice-mode-button',this.checked?'true':'false');
if(typeof window._applyVoiceModePref==='function') window._applyVoiceModePref();
_schedulePreferencesAutosave();
};
}
// TTS engine selector
@@ -8649,11 +8749,19 @@ async function loadSettingsPanel(){
}
});
}
const saved=localStorage.getItem('hermes-tts-engine')||'browser';
const saved=String(_speechSetting('tts_engine','hermes-tts-engine','browser')||'browser');
if(!ttsEngineSel.querySelector('option[value="'+saved+'"]')){
var savedOpt=document.createElement('option');
savedOpt.value=saved; savedOpt.textContent=saved;
ttsEngineSel.appendChild(savedOpt);
}
ttsEngineSel.value=saved;
_syncSpeechPreferenceCache('tts_engine',saved);
ttsEngineSel.onchange=function(){
_markSpeechPreferenceChanged('tts_engine');
localStorage.setItem('hermes-tts-engine',this.value);
window._populateTtsVoices();
_schedulePreferencesAutosave();
};
}
// Populate voice selector based on engine
@@ -8661,7 +8769,8 @@ async function loadSettingsPanel(){
window._populateTtsVoices=function(){
if(!ttsVoiceSel) return;
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
const current=localStorage.getItem('hermes-tts-voice')||'';
const current=String(_speechSetting('tts_voice','hermes-tts-voice','')||'');
_syncSpeechPreferenceCache('tts_voice',current);
if(engine==='elevenlabs'){
ttsVoiceSel.innerHTML='<option value="">Hermy — ElevenLabs (server-configured)</option>';
} else if(engine==='openai'){
@@ -8705,24 +8814,26 @@ async function loadSettingsPanel(){
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
if(engine==='browser') window._populateTtsVoices();
},{once:false});
ttsVoiceSel.onchange=function(){localStorage.setItem('hermes-tts-voice',this.value);};
ttsVoiceSel.onchange=function(){_markSpeechPreferenceChanged('tts_voice');localStorage.setItem('hermes-tts-voice',this.value);_schedulePreferencesAutosave();};
}
// TTS rate/pitch sliders
const ttsRateSlider=$('settingsTtsRate');
const ttsRateValue=$('settingsTtsRateValue');
if(ttsRateSlider){
const savedRate=localStorage.getItem('hermes-tts-rate');
ttsRateSlider.value=savedRate||'1';
const savedRate=_speechSetting('tts_rate','hermes-tts-rate',1);
ttsRateSlider.value=(savedRate===null||savedRate===undefined)?'1':String(savedRate);
if(ttsRateValue) ttsRateValue.textContent=parseFloat(ttsRateSlider.value).toFixed(1)+'x';
ttsRateSlider.oninput=function(){if(ttsRateValue)ttsRateValue.textContent=parseFloat(this.value).toFixed(1)+'x';localStorage.setItem('hermes-tts-rate',this.value);};
_syncSpeechPreferenceCache('tts_rate',ttsRateSlider.value);
ttsRateSlider.oninput=function(){_markSpeechPreferenceChanged('tts_rate');if(ttsRateValue)ttsRateValue.textContent=parseFloat(this.value).toFixed(1)+'x';localStorage.setItem('hermes-tts-rate',this.value);_schedulePreferencesAutosave();};
}
const ttsPitchSlider=$('settingsTtsPitch');
const ttsPitchValue=$('settingsTtsPitchValue');
if(ttsPitchSlider){
const savedPitch=localStorage.getItem('hermes-tts-pitch');
ttsPitchSlider.value=savedPitch||'1';
const savedPitch=_speechSetting('tts_pitch','hermes-tts-pitch',1);
ttsPitchSlider.value=(savedPitch===null||savedPitch===undefined)?'1':String(savedPitch);
if(ttsPitchValue) ttsPitchValue.textContent=parseFloat(ttsPitchSlider.value).toFixed(1);
ttsPitchSlider.oninput=function(){if(ttsPitchValue)ttsPitchValue.textContent=parseFloat(this.value).toFixed(1);localStorage.setItem('hermes-tts-pitch',this.value);};
_syncSpeechPreferenceCache('tts_pitch',ttsPitchSlider.value);
ttsPitchSlider.oninput=function(){_markSpeechPreferenceChanged('tts_pitch');if(ttsPitchValue)ttsPitchValue.textContent=parseFloat(this.value).toFixed(1);localStorage.setItem('hermes-tts-pitch',this.value);_schedulePreferencesAutosave();};
}
const notifCb=$('settingsNotificationsEnabled');
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
@@ -11646,6 +11757,7 @@ async function saveSettings(andClose){
const defaultMessageMode=($('settingsDefaultMessageMode')||{}).value||'steer';
const showBusyPlaceholderHint=!!($('settingsShowBusyPlaceholderHint')||{}).checked;
const body={};
Object.assign(body,_speechPreferencesPayloadFromUi());
if(sendKey) body.send_key=sendKey;
body.theme=theme;
+17 -15
View File
@@ -1,7 +1,7 @@
"""Tests for #4761 configurable voice-mode silence timeout and continuous recognition.
"""Tests for #4761, configurable voice-mode silence timeout and continuous recognition.
The voice-mode loop currently hardcodes:
- SILENCE_MS = 1800 (1.8s pause before auto-send)
The voice-mode loop used to hardcode:
- a startup silence timeout constant (1.8s pause before auto-send)
- _recognition.continuous = false (mic closes after each utterance)
This module pins the fix: both values are now configurable via localStorage keys
@@ -19,7 +19,7 @@ def _boot_src() -> str:
class TestVoiceModeSilenceMsConfig:
"""SILENCE_MS must read from localStorage with 1800 fallback."""
"""The silence timeout must read from localStorage with 1800 fallback."""
def test_silence_ms_reads_local_storage_with_fallback(self):
src = _boot_src()
@@ -29,23 +29,25 @@ class TestVoiceModeSilenceMsConfig:
assert re.search(
r"parseInt\s*\(\s*localStorage\.getItem\s*\(\s*'hermes-voice-silence-ms'\s*\)",
src,
), "SILENCE_MS must read the 'hermes-voice-silence-ms' localStorage key via parseInt."
), "Voice mode must read the 'hermes-voice-silence-ms' localStorage key via parseInt."
# The 1800 default must remain the fallback for missing/invalid values.
assert re.search(r"SILENCE_MS\s*=.*\b1800\b", src), (
"SILENCE_MS must keep 1800 as the default fallback so behavior is "
assert re.search(r"return\s*\(.*\)\?Math\.max\(200,_silenceMsRaw\):1800", src), (
"Voice mode must keep 1800 as the default fallback so behavior is "
"unchanged when the key is unset or invalid."
)
# A non-positive / mistyped value must not be honored verbatim (no instant
# auto-send): the value is guarded by a positivity check and/or a min floor.
# A non-positive or mistyped value must not be honored verbatim.
assert "_silenceMsRaw>0" in src or "Math.max(" in src or "> 0" in src, (
"SILENCE_MS must guard against non-positive values (positivity check "
"Voice mode must guard against non-positive values (positivity check "
"or a Math.max floor) so a mistyped tiny/negative value can't make the "
"recognizer auto-send instantly."
)
def test_silence_ms_used_in_timeout(self):
def test_silence_ms_read_at_timeout_use_time(self):
src = _boot_src()
assert "SILENCE_MS" in src, "SILENCE_MS must still be referenced in the timeout call."
assert "_voiceSilenceMs()" in src, "The silence timeout must be read when scheduling auto-send."
assert re.search(r"setTimeout\s*\(\s*\(\)\s*=>\s*\{\s*_voiceModeSend\(\);\s*\}\s*,\s*_voiceSilenceMs\(\)\s*\)", src, re.S), (
"Voice mode must call _voiceSilenceMs() inside the setTimeout path so mirrored server settings apply without reload."
)
class TestVoiceModeContinuousConfig:
@@ -73,11 +75,11 @@ class TestVoiceModeContinuousConfig:
class TestBootJsVoiceSectionIntegrity:
"""Smoke checks the surrounding voice-mode infrastructure is intact."""
"""Smoke checks, the surrounding voice-mode infrastructure is intact."""
def test_voice_mode_declares_silence_ms(self):
def test_voice_mode_declares_silence_helper(self):
src = _boot_src()
assert "SILENCE_MS" in src, "SILENCE_MS constant must exist in boot.js"
assert "function _voiceSilenceMs()" in src, "The voice silence timeout helper must exist in boot.js"
def test_voice_mode_declares_recognition(self):
src = _boot_src()
@@ -0,0 +1,435 @@
"""Regression checks for #5435 TTS and voice preference persistence."""
import json
import importlib
import pathlib
import urllib.error
import urllib.request
from tests._pytest_port import BASE
ROOT = pathlib.Path(__file__).resolve().parent.parent
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
SPEECH_DEFAULTS = {
"tts_enabled": False,
"tts_auto_read": False,
"tts_engine": "browser",
"tts_voice": "",
"tts_rate": 1.0,
"tts_pitch": 1.0,
"voice_mode_button": False,
"voice_continuous": False,
"voice_silence_ms": 1800,
"raw_audio_mode": False,
}
PERSISTED_SPEECH_KEYS_FIELD = "persisted_speech_keys"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as response:
return json.loads(response.read()), response.status
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
return json.loads(response.read()), response.status
except urllib.error.HTTPError as exc:
return json.loads(exc.read()), exc.code
def _extract_balanced_block(src, marker):
start = src.index(marker)
brace = src.index("{", start)
depth = 0
end = None
for idx in range(brace, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = idx + 1
break
assert end is not None, f"Unbalanced block for {marker!r}"
return src[start:end]
def _settings_file_snapshot():
cfg = importlib.import_module("api.config")
path = cfg.SETTINGS_FILE
original = path.read_text(encoding="utf-8") if path.exists() else None
return path, original
def _restore_settings_file(path, original):
if original is None:
if path.exists():
path.unlink()
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(original, encoding="utf-8")
def test_settings_api_exposes_tts_voice_and_raw_audio_defaults():
data, status = get("/api/settings")
assert status == 200
assert data[PERSISTED_SPEECH_KEYS_FIELD] == []
for key, value in SPEECH_DEFAULTS.items():
assert data[key] == value
def test_settings_api_round_trips_speech_preferences():
path, original = _settings_file_snapshot()
payload = {
"tts_enabled": True,
"tts_auto_read": True,
"tts_engine": "voicevox_local",
"tts_voice": "en-US-AriaNeural",
"tts_rate": "1.4",
"tts_pitch": "0",
"voice_mode_button": True,
"voice_continuous": True,
"voice_silence_ms": "2400",
"raw_audio_mode": True,
}
try:
saved, status = post("/api/settings", payload)
reloaded, reload_status = get("/api/settings")
assert status == 200
assert reload_status == 200
assert saved["tts_enabled"] is True
assert saved["tts_auto_read"] is True
assert saved["tts_engine"] == "voicevox_local"
assert saved["tts_voice"] == "en-US-AriaNeural"
assert saved["tts_rate"] == 1.4
assert saved["tts_pitch"] == 0.0
assert saved["voice_mode_button"] is True
assert saved["voice_continuous"] is True
assert saved["voice_silence_ms"] == 2400
assert saved["raw_audio_mode"] is True
assert saved[PERSISTED_SPEECH_KEYS_FIELD] == sorted(payload)
for key in payload:
expected = saved[key]
assert reloaded[key] == expected
assert reloaded[PERSISTED_SPEECH_KEYS_FIELD] == sorted(payload)
finally:
_restore_settings_file(path, original)
def test_settings_api_reports_only_raw_persisted_speech_keys():
path, original = _settings_file_snapshot()
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"show_tps": True,
"tts_pitch": 0.0,
"voice_mode_button": False,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
data, status = get("/api/settings")
assert status == 200
assert data[PERSISTED_SPEECH_KEYS_FIELD] == [
"tts_pitch",
"voice_mode_button",
]
assert data["tts_pitch"] == 0.0
assert data["voice_mode_button"] is False
assert data["tts_enabled"] is False
finally:
_restore_settings_file(path, original)
def test_unrelated_settings_save_does_not_materialize_absent_speech_defaults():
path, original = _settings_file_snapshot()
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"show_tps": False}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
saved, status = post("/api/settings", {"show_tps": True})
assert status == 200
assert saved["show_tps"] is True
assert saved[PERSISTED_SPEECH_KEYS_FIELD] == []
raw = json.loads(path.read_text(encoding="utf-8"))
assert raw["show_tps"] is True
for key in SPEECH_DEFAULTS:
assert key not in raw
finally:
_restore_settings_file(path, original)
def test_startup_workspace_repair_write_drops_merged_speech_defaults():
path, original = _settings_file_snapshot()
cfg = importlib.import_module("api.config")
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"show_tps": False,
"tts_pitch": 0.0,
"default_workspace": "C:/stale/workspace",
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
merged = cfg.load_settings()
merged["default_workspace"] = "C:/fixed/workspace"
persisted = cfg._settings_payload_for_write(
merged, cfg._extract_persisted_speech_keys(cfg._read_raw_settings_file())
)
assert persisted["show_tps"] is False
assert persisted["tts_pitch"] == 0.0
assert persisted["default_workspace"] == "C:/fixed/workspace"
assert PERSISTED_SPEECH_KEYS_FIELD not in persisted
for key in SPEECH_DEFAULTS:
if key != "tts_pitch":
assert key not in persisted
finally:
_restore_settings_file(path, original)
def test_invalid_speech_settings_preserve_previous_values_and_unrelated_settings():
path, original = _settings_file_snapshot()
data, status = get("/api/settings")
original_show_tps = bool(data.get("show_tps"))
valid = {
"tts_engine": "edge",
"tts_voice": "zh-CN-XiaoxiaoNeural",
"tts_rate": 1.2,
"tts_pitch": 1.1,
"voice_silence_ms": 2200,
}
try:
saved, status = post("/api/settings", valid)
assert status == 200
assert saved["tts_engine"] == "edge"
invalid, status = post(
"/api/settings",
{
"tts_engine": "",
"tts_voice": "x" * 201,
"tts_rate": "nan",
"tts_pitch": 3,
"voice_silence_ms": 199,
"show_tps": not original_show_tps,
},
)
assert status == 200
for key, value in valid.items():
assert invalid[key] == value
assert invalid["show_tps"] is (not original_show_tps)
finally:
_restore_settings_file(path, original)
def test_backend_schema_contains_typed_speech_validation():
for key in SPEECH_DEFAULTS:
assert f'"{key}"' in CONFIG_PY
assert '"voice_silence_ms": (200, 60000)' in CONFIG_PY
assert '"tts_rate": (0.5, 2.0)' in CONFIG_PY
assert '"tts_pitch": (0.0, 2.0)' in CONFIG_PY
assert "_SETTINGS_TTS_ENGINE_RE" in CONFIG_PY
assert 'k == "tts_voice"' in CONFIG_PY
def test_boot_mirrors_server_settings_before_tts_apply_and_preserves_failure_fallback():
mirror_idx = BOOT_JS.index("function _mirrorSpeechSettingsFromServer")
success_call_idx = BOOT_JS.index("_mirrorSpeechSettingsFromServer(s);", mirror_idx)
apply_idx = BOOT_JS.index("_applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true')", success_call_idx)
catch_idx = BOOT_JS.index("}catch(e){", success_call_idx)
failure_apply_idx = BOOT_JS.index("_applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true')", catch_idx)
assert success_call_idx < apply_idx
assert catch_idx < failure_apply_idx
assert "const defaults={" in BOOT_JS
assert "Array.isArray(s.persisted_speech_keys) ? s.persisted_speech_keys : []" in BOOT_JS
assert "if(!hasServerValue(settingKey)&&cached!==null)" in BOOT_JS
for storage_key in [
"hermes-tts-enabled",
"hermes-tts-auto-read",
"hermes-tts-engine",
"hermes-tts-voice",
"hermes-tts-rate",
"hermes-tts-pitch",
"hermes-voice-mode-button",
"hermes-voice-continuous",
"hermes-voice-silence-ms",
"hermes-raw-audio-mode",
]:
assert storage_key in BOOT_JS
assert "window._applyRawAudioModePreference" in BOOT_JS
def test_persisted_speech_key_metadata_controls_boot_and_panel_precedence():
mirror_fn = _extract_balanced_block(BOOT_JS, "function _mirrorSpeechSettingsFromServer")
speech_helpers_start = PANELS_JS.index("const _SETTINGS_SPEECH_STORAGE_KEYS=")
speech_helpers_end = PANELS_JS.index("function _preferencesPayloadFromUi", speech_helpers_start)
speech_helpers_block = PANELS_JS[speech_helpers_start:speech_helpers_end].strip()
speech_setting_start = PANELS_JS.index("const persistedSpeechKeys = new Set(")
speech_setting_end = PANELS_JS.index("const _speechBool=function", speech_setting_start)
speech_setting_block = PANELS_JS[speech_setting_start:speech_setting_end].strip()
script = f"""
const assert = require('assert');
const localStorage = {{
store: new Map([
['hermes-tts-enabled', 'true'],
['hermes-tts-pitch', '0.8'],
]),
getItem(key) {{
return this.store.has(key) ? this.store.get(key) : null;
}},
setItem(key, value) {{
this.store.set(key, String(value));
}},
}};
const window = {{}};
{speech_helpers_block}
{mirror_fn}
_mirrorSpeechSettingsFromServer({{tts_enabled: false, tts_pitch: 1, persisted_speech_keys: []}});
assert.strictEqual(localStorage.getItem('hermes-tts-enabled'), 'true');
assert.strictEqual(localStorage.getItem('hermes-tts-pitch'), '0.8');
{{
let settings = {{tts_enabled: false, tts_pitch: 1, persisted_speech_keys: []}};
{speech_setting_block}
assert.strictEqual(_speechSetting('tts_enabled', 'hermes-tts-enabled', false, 'bool'), 'true');
assert.strictEqual(_speechSetting('tts_pitch', 'hermes-tts-pitch', 1), '0.8');
}}
_mirrorSpeechSettingsFromServer({{tts_enabled: false, tts_pitch: 1, persisted_speech_keys: ['tts_enabled', 'tts_pitch']}});
assert.strictEqual(localStorage.getItem('hermes-tts-enabled'), 'false');
assert.strictEqual(localStorage.getItem('hermes-tts-pitch'), '1');
{{
let settings = {{tts_enabled: false, tts_pitch: 1, persisted_speech_keys: ['tts_enabled', 'tts_pitch']}};
{speech_setting_block}
assert.strictEqual(_speechSetting('tts_enabled', 'hermes-tts-enabled', false, 'bool'), false);
assert.strictEqual(_speechSetting('tts_pitch', 'hermes-tts-pitch', 1), 1);
}}
"""
import subprocess
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_settings_panel_speech_payload_is_sparse_by_ownership():
speech_helpers_start = PANELS_JS.index("const _SETTINGS_SPEECH_STORAGE_KEYS=")
speech_helpers_end = PANELS_JS.index("function _setPreferencesAutosaveStatus", speech_helpers_start)
speech_helpers_block = PANELS_JS[speech_helpers_start:speech_helpers_end].strip()
script = f"""
const assert = require('assert');
const localStorage = {{
store: new Map(),
getItem(key) {{
return this.store.has(key) ? this.store.get(key) : null;
}},
setItem(key, value) {{
this.store.set(key, String(value));
}},
clear() {{
this.store.clear();
}},
}};
const controls = {{
settingsTtsEnabled: {{checked: false}},
settingsTtsAutoRead: {{checked: false}},
settingsTtsEngine: {{value: 'browser'}},
settingsTtsVoice: {{value: ''}},
settingsTtsRate: {{value: '1'}},
settingsTtsPitch: {{value: '1'}},
settingsVoiceModeEnabled: {{checked: false}},
settingsRawAudio: {{checked: false}},
}};
function $(id) {{ return controls[id] || null; }}
{speech_helpers_block}
_captureSpeechPreferenceOwnership({{persisted_speech_keys: []}});
assert.deepStrictEqual(_speechPreferencesPayloadFromUi(), {{}});
_captureSpeechPreferenceOwnership({{persisted_speech_keys: ['tts_enabled']}});
assert.deepStrictEqual(_speechPreferencesPayloadFromUi(), {{tts_enabled: false}});
localStorage.clear();
localStorage.setItem('hermes-tts-pitch', '0.8');
controls.settingsTtsPitch.value = '0.8';
_captureSpeechPreferenceOwnership({{persisted_speech_keys: []}});
assert.deepStrictEqual(_speechPreferencesPayloadFromUi(), {{tts_pitch: 0.8}});
localStorage.clear();
controls.settingsTtsRate.value = '1.4';
_captureSpeechPreferenceOwnership({{persisted_speech_keys: []}});
_markSpeechPreferenceChanged('tts_rate');
assert.deepStrictEqual(_speechPreferencesPayloadFromUi(), {{tts_rate: 1.4}});
"""
import subprocess
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_settings_panel_persists_speech_fields_and_keeps_immediate_cache_writes():
payload_idx = PANELS_JS.index("function _preferencesPayloadFromUi")
payload_end = PANELS_JS.index("function _setPreferencesAutosaveStatus", payload_idx)
payload_block = PANELS_JS[payload_idx:payload_end]
panel_idx = PANELS_JS.index("TTS settings use /api/settings as the durable source")
panel_end = PANELS_JS.index("const notifCb=$('settingsNotificationsEnabled')", panel_idx)
panel_block = PANELS_JS[panel_idx:panel_end]
for field in SPEECH_DEFAULTS:
assert f"_setOwnedSpeechPayload(payload,'{field}'" in payload_block
for storage_key in [
"hermes-tts-enabled",
"hermes-tts-auto-read",
"hermes-tts-engine",
"hermes-tts-voice",
"hermes-tts-rate",
"hermes-tts-pitch",
"hermes-voice-mode-button",
"hermes-voice-continuous",
"hermes-voice-silence-ms",
"hermes-raw-audio-mode",
]:
assert storage_key in panel_block or storage_key in payload_block
assert "_speechSetting('tts_engine','hermes-tts-engine','browser')" in panel_block
assert "function _speechPreferencesPayloadFromUi()" in PANELS_JS
assert "savedRate||'1'" not in panel_block
assert "savedPitch||'1'" not in panel_block
assert "ttsRateSlider.value=(savedRate===null||savedRate===undefined)?'1':String(savedRate)" in panel_block
assert "ttsPitchSlider.value=(savedPitch===null||savedPitch===undefined)?'1':String(savedPitch)" in panel_block
assert "if(settings&&persistedSpeechKeys.has(key)) return settings[key];" in PANELS_JS
assert "function _captureSpeechPreferenceOwnership(settings)" in PANELS_JS
assert "function _speechPreferenceIsOwned(settingKey)" in PANELS_JS
assert "_markSpeechPreferenceChanged('tts_rate')" in panel_block
assert "_syncSpeechPreferenceCache('tts_rate',ttsRateSlider.value)" in panel_block
assert "_syncSpeechPreferenceCache('tts_pitch',ttsPitchSlider.value)" in panel_block
assert "Object.assign(payload,_speechPreferencesPayloadFromUi());" in payload_block
assert "Object.assign(body,_speechPreferencesPayloadFromUi());" in PANELS_JS
assert "_schedulePreferencesAutosave()" in panel_block
assert "_applyVoiceModePref" in panel_block
assert "_populateTtsVoices" in panel_block