mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-19 06:00:46 +00:00
Merge remote-tracking branch 'origin/master' into release/stage-p1c
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user