diff --git a/api/routes.py b/api/routes.py index 235e857d..0228a543 100644 --- a/api/routes.py +++ b/api/routes.py @@ -19367,10 +19367,7 @@ def _handle_chat_start(handler, body, diag=None): if "model_provider" in body else getattr(s, "model_provider", None) ) -<<<<<<< ours _pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider) -======= ->>>>>>> theirs explicit_model_pick = bool(body.get("explicit_model_pick")) moa_config = None gateway_chat_enabled = webui_gateway_chat_enabled(get_config()) @@ -19383,7 +19380,6 @@ def _handle_chat_start(handler, body, diag=None): moa_config = resolve_moa_config() except RuntimeError as e: return bad(handler, str(e), 503) - _pp_provider, _pp_default = _read_profile_model_config(s, requested_provider) diag.stage("resolve_model_provider") if diag else None model, model_provider, normalized_model = _resolve_compatible_session_model_state( requested_model, diff --git a/static/boot.js b/static/boot.js index ad975fdd..c309f90d 100644 --- a/static/boot.js +++ b/static/boot.js @@ -645,6 +645,11 @@ function _micToastKeyForRecognitionError(error){ let _finalText=''; let _prefix=''; let _isRecording=false; + let _micHoldTimer=null; + let _micHoldActive=false; + let _micPointerDown=false; + let _micStartSeq=0; + const _micHoldThresholdMs=300; function _setButtonTooltipAndKey(btn, key){ const text = t(key); @@ -751,14 +756,16 @@ function _micToastKeyForRecognitionError(error){ } } - function _stopTracks(){ - if(mediaStream){ - mediaStream.getTracks().forEach(track=>track.stop()); - mediaStream=null; + function _stopTracks(stream=mediaStream){ + if(stream){ + stream.getTracks().forEach(track=>track.stop()); + if(mediaStream===stream) mediaStream=null; } } function _stopMic(){ + _micStartSeq+=1; + _isRecording=false; if(!window._micActive) return; // Stop the backend that was ACTIVE WHEN RECORDING STARTED — not whatever // _rawAudioMode says now. The user can toggle Settings → Sound mid-recording, @@ -868,7 +875,31 @@ function _micToastKeyForRecognitionError(error){ _probeServerSttCapability(); - btn.onclick=async()=>{ + function _clearMicHoldTimer(){ + if(_micHoldTimer){ + clearTimeout(_micHoldTimer); + _micHoldTimer=null; + } + } + + function _resetMicHoldState(){ + _clearMicHoldTimer(); + _micHoldActive=false; + _micPointerDown=false; + } + + function _micButtonAvailable(){ + if(!btn||btn.disabled) return false; + if(btn.style.display==='none') return false; + if(btn.classList.contains('composer-control-hidden')) return false; + if(btn.getAttribute('aria-hidden')==='true') return false; + if(window.getComputedStyle&&window.getComputedStyle(btn).display==='none') return false; + return true; + } + + async function _startMicCapture(holdRequired=false){ + if(!_micButtonAvailable()) return; + const startSeq=++_micStartSeq; // Race-condition guard: ignore rapid double-clicks if(_isRecording){ _stopMic(); @@ -900,26 +931,38 @@ function _micToastKeyForRecognitionError(error){ return; } try{ - mediaStream=await navigator.mediaDevices.getUserMedia({audio:true}); + const captureStream=await navigator.mediaDevices.getUserMedia({audio:true}); + if(startSeq!==_micStartSeq||!_micButtonAvailable()||(holdRequired&&!_micHoldActive)){ + _isRecording=false; + _stopTracks(captureStream); + return; + } + mediaStream=captureStream; const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg']; const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||''; - mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined); + const captureMode=_rawAudioMode?'media-raw':'media-transcribe'; + const recorder=new MediaRecorder(captureStream,mimeType?{mimeType}:undefined); audioChunks=[]; - mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);}; - mediaRecorder.onerror=()=>{ + const captureChunks=audioChunks; + recorder.ondataavailable=e=>{if(e.data&&e.data.size)captureChunks.push(e.data);}; + recorder.onerror=()=>{ + const isCurrentCapture=mediaRecorder===recorder||mediaStream===captureStream; _isRecording=false; - _setRecording(false); + if(mediaRecorder===recorder) mediaRecorder=null; + if(isCurrentCapture) _setRecording(false); window._micPendingSend=false; - _stopTracks(); + _stopTracks(captureStream); showToast(t('mic_network')); }; - mediaRecorder.onstop=async()=>{ + recorder.onstop=async()=>{ + const isCurrentCapture=mediaRecorder===recorder||mediaStream===captureStream; + if(mediaRecorder===recorder) mediaRecorder=null; _isRecording=false; - const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'}); - _setRecording(false); - _stopTracks(); + const blob=new Blob(captureChunks,{type:recorder.mimeType||mimeType||'audio/webm'}); + if(isCurrentCapture) _setRecording(false); + _stopTracks(captureStream); if(blob.size){ - if(_activeCaptureMode==='media-raw'){ + if(captureMode==='media-raw'){ await _sendRawAudio(blob); }else{ await _transcribeBlob(blob); @@ -930,16 +973,81 @@ function _micToastKeyForRecognitionError(error){ } _applyDeferredServerSttFlip(); }; - _activeCaptureMode=_rawAudioMode?'media-raw':'media-transcribe'; - mediaRecorder.start(); + _activeCaptureMode=captureMode; + mediaRecorder=recorder; + recorder.start(); _setRecording(true); }catch(err){ + if(startSeq!==_micStartSeq) return; _isRecording=false; window._micPendingSend=false; _stopTracks(); showToast(t(_micToastKeyForRecognitionError('not-allowed')||'mic_denied')); } - }; + } + + async function _toggleMicCapture(){ + if(!_micButtonAvailable()) return; + if(window._micActive){ + _stopMic(); + return; + } + await _startMicCapture(); + } + window._toggleMicCapture=_toggleMicCapture; + + btn.addEventListener('pointerdown',e=>{ + if(e.button!==0) return; + if(!_micButtonAvailable()) return; + _resetMicHoldState(); + _micPointerDown=true; + _micHoldTimer=setTimeout(async()=>{ + _micHoldTimer=null; + if(!_micPointerDown||window._micActive) return; + _micHoldActive=true; + await _startMicCapture(true); + },_micHoldThresholdMs); + }); + + btn.addEventListener('pointerup',async e=>{ + if(e.button!==0||!_micPointerDown) return; + _clearMicHoldTimer(); + if(_micHoldActive){ + _micHoldActive=false; + _micPointerDown=false; + _stopMic(); + return; + } + _micPointerDown=false; + await _toggleMicCapture(); + }); + + btn.addEventListener('pointerleave',()=>{ + _clearMicHoldTimer(); + if(_micHoldActive){ + _micHoldActive=false; + _micPointerDown=false; + _stopMic(); + return; + } + _micPointerDown=false; + }); + + btn.addEventListener('pointercancel',()=>{ + _clearMicHoldTimer(); + if(_micHoldActive){ + _micHoldActive=false; + _micPointerDown=false; + _stopMic(); + return; + } + _micPointerDown=false; + }); + + btn.addEventListener('click',async e=>{ + if(e.detail!==0) return; + await _toggleMicCapture(); + }); // Wire up the settings checkbox const rawAudioCheckbox = document.getElementById('settingsRawAudio'); diff --git a/static/commands.js b/static/commands.js index 538546aa..2674b683 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1644,7 +1644,15 @@ function cmdReasoning(args){ } function cmdVoice(){ const mic=document.getElementById('btnMic'); - if(mic&&mic.style.display!=='none'&&!mic.disabled){try{mic.click();return;}catch(_){}} + const micVisible=!!( + mic + && mic.style.display!=='none' + && !mic.disabled + && !mic.classList.contains('composer-control-hidden') + && mic.getAttribute('aria-hidden')!=='true' + && (!window.getComputedStyle||window.getComputedStyle(mic).display!=='none') + ); + if(micVisible){try{mic.click();return;}catch(_){}} showToast(t('cmd_voice_use_mic')); } diff --git a/tests/test_bugbatch_apr2026.py b/tests/test_bugbatch_apr2026.py index ff2c7d13..32d0a797 100644 --- a/tests/test_bugbatch_apr2026.py +++ b/tests/test_bugbatch_apr2026.py @@ -177,13 +177,15 @@ def test_590_transcribing_status_shown_before_fetch(): def test_590_recording_stops_before_transcribe(): - """boot.js: _setRecording(false) must fire in onstop before _transcribeBlob.""" - onstop_start = BOOT_JS.find("mediaRecorder.onstop") - assert onstop_start != -1, "mediaRecorder.onstop not found" - onstop_body = BOOT_JS[onstop_start:onstop_start + 400] + """boot.js: _setRecording(false) must fire in onstop before the pinned send path.""" + onstop_start = BOOT_JS.find("recorder.onstop") + assert onstop_start != -1, "recorder.onstop not found" + onstop_body = BOOT_JS[onstop_start:onstop_start + 700] rec_pos = onstop_body.find("_setRecording(false)") blob_pos = onstop_body.find("_transcribeBlob(") - assert rec_pos != -1 and blob_pos != -1 - assert rec_pos < blob_pos, ( - "_setRecording(false) must come before _transcribeBlob so mic icon clears immediately" + raw_pos = onstop_body.find("_sendRawAudio(") + send_pos = raw_pos if raw_pos != -1 and (blob_pos == -1 or raw_pos < blob_pos) else blob_pos + assert rec_pos != -1 and send_pos != -1 + assert rec_pos < send_pos, ( + "_setRecording(false) must come before the pinned send path so the mic icon clears immediately" ) diff --git a/tests/test_issue3700_push_to_talk.py b/tests/test_issue3700_push_to_talk.py new file mode 100644 index 00000000..e88e2a20 --- /dev/null +++ b/tests/test_issue3700_push_to_talk.py @@ -0,0 +1,92 @@ +import re +from pathlib import Path + + +def _boot_src() -> str: + return Path("static/boot.js").read_text(encoding="utf-8") + + +def _commands_src() -> str: + return Path("static/commands.js").read_text(encoding="utf-8") + + +def test_start_mic_capture_extracted(): + src = _boot_src() + assert re.search(r"async function _startMicCapture\([^)]*\)\{", src) + assert "_activeCaptureMode='speech'" in src + assert "const captureMode=_rawAudioMode?'media-raw':'media-transcribe';" in src + assert "_activeCaptureMode=captureMode;" in src + + +def test_toggle_mic_capture_exposed_on_window(): + src = _boot_src() + assert "async function _toggleMicCapture()" in src + assert "window._toggleMicCapture=_toggleMicCapture;" in src + + +def test_toggle_helper_respects_hidden_mic_setting(): + src = _boot_src() + assert "function _micButtonAvailable()" in src + assert "btn.classList.contains('composer-control-hidden')" in src + assert "btn.getAttribute('aria-hidden')==='true'" in src + + +def test_voice_command_respects_hidden_mic_setting_before_clicking(): + src = _commands_src() + assert "function cmdVoice()" in src + assert "mic.classList.contains('composer-control-hidden')" in src + assert "mic.getAttribute('aria-hidden')!=='true'" in src + assert "showToast(t('cmd_voice_use_mic'));" in src + + +def test_old_onclick_handler_removed(): + src = _boot_src() + assert "btn.onclick=async()=>{" not in src + + +def test_pointer_hold_wiring_present(): + src = _boot_src() + assert "let _micHoldTimer=null;" in src + assert "let _micHoldActive=false;" in src + assert "let _micPointerDown=false;" in src + assert "let _micStartSeq=0;" in src + assert "const _micHoldThresholdMs=300;" in src + assert "btn.addEventListener('pointerdown'" in src + assert "btn.addEventListener('pointerup'" in src + assert ( + "btn.addEventListener('pointerleave'" in src + or "btn.addEventListener('pointercancel'" in src + ) + assert "_micHoldTimer=setTimeout(async()=>{" in src + assert "},_micHoldThresholdMs);" in src + + +def test_hold_release_routes_to_stop_mic(): + src = _boot_src() + assert re.search( + r"btn\.addEventListener\('pointerup',async e=>\{.*?_stopMic\(\);.*?\}\);", + src, + re.DOTALL, + ) + assert re.search( + r"btn\.addEventListener\('pointer(cancel|leave)',\(\)=>\{.*?_stopMic\(\);.*?\}\);", + src, + re.DOTALL, + ) + assert "const startSeq=++_micStartSeq;" in src + assert "if(startSeq!==_micStartSeq||!_micButtonAvailable()||(holdRequired&&!_micHoldActive)){" in src + assert "_stopTracks(captureStream);" in src + assert "if(startSeq!==_micStartSeq) return;" in src + + +def test_reserved_ctrl_shift_d_shortcut_is_not_bound(): + src = _boot_src() + assert "(e.metaKey||e.ctrlKey)&&e.shiftKey&&!e.altKey&&(e.key==='d'||e.key==='D')" not in src + assert "await window._toggleMicCapture();" not in src + + +def test_stop_mic_still_exposed_and_active_capture_mode_retained(): + src = _boot_src() + assert "window._stopMic=_stopMic;" in src + assert "_activeCaptureMode" in src + assert "_stopMic();" in src diff --git a/tests/test_issue4571_mic_insecure_origin.py b/tests/test_issue4571_mic_insecure_origin.py index 5f478b2f..a9459ad8 100644 --- a/tests/test_issue4571_mic_insecure_origin.py +++ b/tests/test_issue4571_mic_insecure_origin.py @@ -70,8 +70,8 @@ def test_permission_style_speech_errors_use_insecure_origin_key_only_on_that_bra def test_dictation_preflights_insecure_origin_before_speech_or_media_capture(): body = _slice_between( BOOT_JS, - "btn.onclick=async()=>{", - "\n // Wire up the settings checkbox", + "async function _startMicCapture(holdRequired=false){", + "\n\n async function _toggleMicCapture(){", ) insecure_idx = body.index("_micOriginNeedsSecureContext()") speech_idx = body.index("recognition.start()") diff --git a/tests/test_raw_audio_upload.py b/tests/test_raw_audio_upload.py index c50d7973..ba045ca9 100644 --- a/tests/test_raw_audio_upload.py +++ b/tests/test_raw_audio_upload.py @@ -159,10 +159,11 @@ def test_stop_mic_uses_pinned_active_capture_mode_not_current_rawmode(): # _stopMic decides backend from the pinned mode, not _rawAudioMode. assert "_activeCaptureMode==='speech'" in _BOOT_JS # onstop dispatches raw-vs-transcribe from the pinned mode too. - assert "_activeCaptureMode==='media-raw'" in _BOOT_JS + assert "if(captureMode==='media-raw')" in _BOOT_JS # the mode is pinned at both start branches. assert "_activeCaptureMode='speech'" in _BOOT_JS - assert "_activeCaptureMode=_rawAudioMode?'media-raw':'media-transcribe'" in _BOOT_JS + assert "const captureMode=_rawAudioMode?'media-raw':'media-transcribe';" in _BOOT_JS + assert "_activeCaptureMode=captureMode;" in _BOOT_JS def test_send_raw_audio_honors_explicit_pending_send(): diff --git a/tests/test_sprint20.py b/tests/test_sprint20.py index 67e393a6..259e3849 100644 --- a/tests/test_sprint20.py +++ b/tests/test_sprint20.py @@ -425,11 +425,10 @@ def test_boot_js_prefix_variable_declared(): def test_boot_js_prefix_captured_on_start(): """_prefix must be set from ta.value when the user starts recording.""" js, _ = get_text("/static/boot.js") - # _prefix assignment must happen in the btn.onclick else branch (before recognition.start) - btn_onclick_idx = js.find("btn.onclick") - btn_onclick_end = js.find("};", btn_onclick_idx) - onclick_body = js[btn_onclick_idx:btn_onclick_end] - assert "_prefix=ta.value" in onclick_body or "_prefix = ta.value" in onclick_body + start_idx = js.find("async function _startMicCapture") + start_end = js.find("async function _toggleMicCapture", start_idx) + start_body = js[start_idx:start_end] + assert "_prefix=ta.value" in start_body or "_prefix = ta.value" in start_body def test_boot_js_onresult_prepends_prefix():