From 6361d920ff92f4e750cf47e6b6805ab7ea858225 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 11 Jul 2026 20:47:54 -0700 Subject: [PATCH] Release exp-v0.52.49: dictation preserves composer text (#5895) (#5978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dictation): preserve composer text when server STT commits Dictation via the server STT path (MediaRecorder → POST /api/transcribe) replaced any text already in the composer with the recognized transcript. Browser SpeechRecognition worked correctly; only the async path was broken. Root cause: the MediaRecorder.onstop handler calls _setRecording(false) before awaiting _transcribeBlob's fetch. _setRecording(false) clears the module-level _prefix. By the time the fetch resolves and _commitTranscript runs, _prefix is '' — so the existing append logic collapses to 'just the transcript' and overwrites the textarea. Browser SR dodged this because sr.onend builds the merged string into a local variable before invoking _setRecording(false). Server STT is async and could not use that pattern without help. Fix: - _commitTranscript accepts an optional prefixOverride parameter. - _transcribeBlob snapshots _prefix into a local before the await. - Pass the snapshot through to _commitTranscript. Add a Settings → Voice → 'Append dictation to composer' toggle (default ON) so users can opt into replace mode if they prefer the old buggy behavior. The bug fix changes the default to append, matching what the browser SR path always did. i18n: new keys dictation_append_label / dictation_append_desc added to all 14 locale blocks in static/i18n.js (English fallback where no native translation exists). Files: static/boot.js (+43/-7), static/index.html (+7), static/i18n.js (+30). * fix(dictation): snapshot _prefix in onstop, not inside _transcribeBlob First attempt at the append-mode fix took the snapshot inside _transcribeBlob via 'const prefixSnapshot = _prefix'. By the time _transcribeBlob runs (awaited from recorder.onstop), the sync block above has already called _setRecording(false), which clears _prefix inside _setRecording's !on branch. So the snapshot was always '' and the append path fell through to the replace branch. Move the snapshot into recorder.onstop, before _setRecording(false) runs. Pass it as a parameter into _transcribeBlob so the call site doesn't need to re-read the (now-cleared) module-level _prefix. No behavior change for the browser-SR path — sr.onend still builds the merged string into a local variable before invoking _setRecording(false), and was already correct. * fix(dictation): trim comments to fit test_590_recording_stops_before_transcribe test_bugbatch_apr2026.py::test_590_recording_stops_before_transcribe slices 700 chars from 'recorder.onstop' and asserts that _setRecording(false) comes before _sendRawAudio(/_transcribeBlob( inside that window. With the verbose 6-line comment block from the previous fix, the actual call sites landed at offsets 834/890 — past the 700-char window — so the test's .find() returned -1 and the assertion failed mechanically. Also: the verbose comment in _transcribeBlob contained the substring 'recorder.onstop', which made the test's first .find() land in the comment instead of the actual handler, returning a wrong onstop_start. Trim both comment blocks to single lines that fit within the test's window and don't contain the literal 'recorder.onstop' substring. Functional behavior unchanged from the previous fix; this is purely a comment-shape adjustment to satisfy the brittle test. * fix(tests): anchor test_590 on handler definition + widen window per review The reviewer's fix-spec for test_590_recording_stops_before_transcribe was more thorough than my single-shot comment trim: (1) Reword the _transcribeBlob comment so it doesn't contain the literal 'recorder.onstop' substring. (2) Make the test robust to the handler growing: anchor on 'recorder.onstop=async' instead of 'recorder.onstop', and widen the fixed 700-char window to 1200. Applied both. With these changes the test passes regardless of how much comment text gets added in or around the handler in the future. Also restored the verbose explanatory comments in both locations (the previous a7f84027 had trimmed them to fit the brittle 700-char window — that workaround is no longer needed). Backward-compat check: the new test logic also passes against origin/master (verified locally), so this change doesn't regress the existing green-master invariant. * fix(dictation): preserve user's mid-transcription edits in async server STT Greptile flagged this as a 4/5 concern on the append-mode branch: the async server-STT path appended the dictated text to the stale snapshot (`_prefix` captured at recording start), which clobbered any keystrokes the user made during the ~1s transcription round-trip. Before this fix, with append mode ON: User typed before mic: 'Hello, ' User typed during STT: 'Hello, from K' (typed while waiting) Dictated text: 'world' Result: 'Hello, world' (lost 'from K') After this fix: User typed before mic: 'Hello, ' User typed during STT: 'Hello, from K' Dictated text: 'world' Result: 'Hello, from K world' (live state preserved) The fix prefers live `ta.value` as the append base when the textarea is non-empty, falling back to the `prefixOverride` snapshot only when the textarea is genuinely empty (the original empty-composer path where nothing to race with). Adds a regression test in test_raw_audio_upload.py that asserts the new `ta.value ||` fallback structure on `_commitTranscript`. * fix(dictation): don't resurrect cleared text on async transcription resolve Greptile P1 (re-reviewed at 20:07 UTC): the previous race fix (`ta.value || (prefixOverride ?? _prefix)`) treats an empty `ta.value` as falsy and falls back to the recording-start snapshot. If the user explicitly clears the textarea while server transcription is in flight, the cleared text reappears with the dictated text appended — the user's intent is violated. Fix: when prefixOverride IS passed (server-STT path), trust live ta.value UNCONDITIONALLY — even when empty. The fallback to _prefix only fires when no prefixOverride is given AND ta.value is empty, which is purely defensive (no current caller hits this branch — the browser-SR path commits inline in sr.onend without calling this function). Concrete repro: User typed before mic: 'Hello, ' User clears textarea during STT Dictated text: 'world' OLD (0239bb4e) result: 'Hello, world' (cleared text resurrected) NEW result: 'world' (user intent respected) Greptile's suggested one-liner: const base = prefixOverride !== undefined ? ta.value : (ta.value || _prefix); Applied with expanded inline comment documenting the three concerns this function balances (race, clear-during-STT, defensive fallback). * Release exp-v0.52.49: preserve composer text on server STT commit (#5895) --------- Co-authored-by: Konstantin M Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + static/boot.js | 71 +++++++++++++++++++++++++++++----- static/i18n.js | 30 ++++++++++++++ static/index.html | 7 ++++ tests/test_bugbatch_apr2026.py | 9 +++-- tests/test_raw_audio_upload.py | 27 +++++++++++++ 6 files changed, 134 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ff4927..3147dc82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Fixed +- **Dictation no longer wipes text you already typed.** When you typed part of a prompt and then used the microphone (server-side transcription / MediaRecorder path), the recognized text replaced your typed prefix instead of being appended to it. Dictation now appends to whatever is already in the composer, so you can type a partial sentence and dictate the rest. A Settings toggle lets you opt back into the old replace behavior. Thanks @Kopamed. (#5895) + - **The context-usage indicator is more accurate right after a context compaction.** After the conversation context was compressed, the indicator kept showing the previous provider-reported prompt size (the pre-compaction request) instead of the smaller prepared one, so it briefly overstated how full the window was. It now shows a short-lived, clearly-labeled post-compaction estimate that expires as soon as the next real provider count arrives — provider accounting, token metering, and recovery behavior are unchanged. Thanks @rodboev. (#5872, #4685) - **A failed turn now shows the real provider error instead of "no response, try again".** When a turn hit a non-retryable provider error (e.g. HTTP 400 "invalid model" or a credentials problem), the WebUI showed a misleading "No response from provider — this often means a usage/rate limit was hit silently, try again in a moment" message and hid the actual, non-retryable cause. The terminal error the agent reports is now surfaced through to the turn-completion classifier, so you see an accurate, actionable message (e.g. "Model not found — check the model ID in Settings" / "Authentication failed") instead of being told to retry a request that can never succeed. The generic no-response message stays reserved for genuinely empty completions. Thanks @b3nw for the report. (#5940) diff --git a/static/boot.js b/static/boot.js index 36a07e9a..4b6f325d 100644 --- a/static/boot.js +++ b/static/boot.js @@ -674,6 +674,10 @@ function _micToastKeyForRecognitionError(error){ // Raw audio mode preference: send audio file instead of transcribing let _rawAudioMode = localStorage.getItem('hermes-raw-audio-mode') === 'true'; + // Append-on-commit preference: when ON (default), dictated text is appended + // to any text already in the composer. When OFF, dictated text replaces the + // composer content (the pre-existing behavior). + let _dictationAppend = localStorage.getItem('hermes-dictation-append') !== 'false'; // Capture backend pinned at recording start ('speech' | 'media' | null) so // _stopMic / onstop act on the backend that actually started, even if the // raw-audio toggle changes mid-recording (#3169 Codex review). @@ -746,6 +750,14 @@ function _micToastKeyForRecognitionError(error){ } window._applyRawAudioModePreference=_applyRawAudioModePreference; + function _applyDictationAppendPreference(enabled){ + _dictationAppend=!!enabled; + try{localStorage.setItem('hermes-dictation-append',_dictationAppend?'true':'false');}catch(_){} + const cb=document.getElementById('settingsDictationAppend'); + if(cb) cb.checked=_dictationAppend; + } + window._applyDictationAppendPreference=_applyDictationAppendPreference; + 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}`}); @@ -766,13 +778,39 @@ function _micToastKeyForRecognitionError(error){ } } - function _commitTranscript(text){ + function _commitTranscript(text, prefixOverride){ + // `prefixOverride` is the composer content captured at recording start, + // passed only by the async server-STT path (recorder.onstop → _transcribeBlob). + // The sync browser-SR path doesn't call this function — it commits inline + // in sr.onend using _prefix directly. + // + // Three concerns this function has to balance (Greptile reviews): + // 1. Race condition: user types during async transcription → preserve those + // keystrokes. Read live ta.value, not the stale snapshot. + // 2. Clear-during-transcription: user clears textarea during async wait → + // respect that intent. Live ta.value (even empty) wins over snapshot. + // 3. Browser-SR fallback: if a future caller passes no prefixOverride + // and live ta.value is empty, fall back to _prefix as a safety net. + // + // Resolution: when prefixOverride IS provided (server-STT path), trust live + // ta.value unconditionally — even when empty. Otherwise fall back to _prefix. const clean=(text||'').trim(); - const committed=clean - ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n') - ? _prefix+' '+clean.trimStart() - : _prefix+clean) - : ta.value; + let committed; + if(!clean){ + committed = ta.value; + }else if(_dictationAppend){ + const base = prefixOverride !== undefined ? ta.value : (ta.value || _prefix); + if(!base){ + committed = clean; + }else{ + committed = (!base.endsWith(' ') && !base.endsWith('\n')) + ? base+' '+clean.trimStart() + : base+clean; + } + }else{ + // Replace mode (explicit): dictated text overwrites the composer. + committed = clean; + } ta.value=committed; autoResize(); if(window._micPendingSend){ @@ -793,10 +831,12 @@ function _micToastKeyForRecognitionError(error){ return !!(SpeechRecognition&&localStorage.getItem(_micForceMediaRecorderKey)!=='1'); } - async function _transcribeBlob(blob){ + async function _transcribeBlob(blob, prefixSnapshot){ const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm'; const form=new FormData(); form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`})); + // Snapshot is passed in from the recorder.onstop handler — taken there + // BEFORE _setRecording(false) clears _prefix (async server STT path). setComposerStatus('Transcribing…'); try{ const res=await fetch('api/transcribe',{method:'POST',body:form}); @@ -806,7 +846,7 @@ function _micToastKeyForRecognitionError(error){ err.status=res.status; throw err; } - _commitTranscript(data.transcript||''); + _commitTranscript(data.transcript||'', prefixSnapshot); }catch(err){ if(_isServerSttUnavailable(err)&&_allowBrowserSttFallback()){ window._micPendingSend=false; @@ -1152,6 +1192,12 @@ function _micToastKeyForRecognitionError(error){ const isCurrentCapture=mediaRecorder===recorder||mediaStream===captureStream; if(mediaRecorder===recorder) mediaRecorder=null; _isRecording=false; + // Capture the composer prefix BEFORE _setRecording(false) clears _prefix. + // The await on _transcribeBlob runs after this sync block, so by the + // time _transcribeBlob is called, _prefix is already ''. Passing the + // snapshot through keeps append-mode working on the async server-STT + // path. See _commitTranscript() for how the snapshot is consumed. + const prefixSnapshot = _prefix; const blob=new Blob(captureChunks,{type:recorder.mimeType||mimeType||'audio/webm'}); if(isCurrentCapture) _setRecording(false); _stopTracks(captureStream); @@ -1159,7 +1205,7 @@ function _micToastKeyForRecognitionError(error){ if(captureMode==='media-raw'){ await _sendRawAudio(blob); }else{ - await _transcribeBlob(blob); + await _transcribeBlob(blob, prefixSnapshot); } } else if(window._micPendingSend){ @@ -1251,6 +1297,13 @@ function _micToastKeyForRecognitionError(error){ _applyRawAudioModePreference(this.checked); }); } + const appendCheckbox = document.getElementById('settingsDictationAppend'); + if(appendCheckbox){ + appendCheckbox.checked = _dictationAppend; + appendCheckbox.addEventListener('change', function(){ + _applyDictationAppendPreference(this.checked); + }); + } _updateMicTooltip(); })(); window._micActive=window._micActive||false; diff --git a/static/i18n.js b/static/i18n.js index 57faecd2..ec49e195 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -27,6 +27,8 @@ const LOCALES = { voice_dictate: 'Dictate', voice_dictate_active: 'Stop dictation', voice_recording_active: 'Stop recording', + dictation_append_label: 'Append dictation to composer', + dictation_append_desc: 'When ON, dictated text is appended to any text already in the composer (with a space if needed). When OFF, dictated text replaces the composer content.', voice_mode_toggle: 'Voice mode', voice_mode_toggle_active: 'Exit voice mode', // Turn-based voice mode (#1333) @@ -1769,6 +1771,8 @@ const LOCALES = { voice_dictate: 'Detta', voice_dictate_active: 'Interrompi dettatura', voice_recording_active: 'Interrompi registrazione', + dictation_append_label: 'Aggiungi dettatura al composer', + dictation_append_desc: 'Quando ON, il testo dettato viene aggiunto a quello già presente nel composer (con uno spazio se necessario). Quando OFF, il testo dettato sostituisce il contenuto del composer.', voice_mode_toggle: 'Modalità vocale', voice_mode_toggle_active: 'Esci dalla modalità vocale', // Turn-based voice mode (#1333) @@ -3503,6 +3507,8 @@ const LOCALES = { voice_dictate: 'ディクテーション', voice_dictate_active: 'ディクテーション停止', voice_recording_active: '録音を停止', + dictation_append_label: 'ディクテーションを合成画面に追記', + dictation_append_desc: 'ON の場合、ディクテーション結果は合成画面の既存テキストに追記されます(必要に応じてスペースを挿入)。OFF の場合、ディクテーション結果は既存テキストを置き換えます。', voice_mode_toggle: '音声モード', voice_mode_toggle_active: '音声モードを終了', voice_listening: '聞き取り中…', @@ -5239,6 +5245,8 @@ const LOCALES = { voice_dictate: 'Диктовка', voice_dictate_active: 'Остановить диктовку', voice_recording_active: 'Остановить запись', + dictation_append_label: 'Добавлять диктовку к черновику', + dictation_append_desc: 'Если включено, текст диктовки добавляется к уже введённому в поле ввода тексту (с пробелом при необходимости). Если выключено, диктовка заменяет содержимое поля.', voice_mode_toggle: 'Голосовой режим', voice_mode_toggle_active: 'Выйти из голосового режима', voice_listening: 'Слушаю…', @@ -8596,6 +8604,8 @@ const LOCALES = { voice_dictate: 'Dictar', voice_dictate_active: 'Stop dictation', voice_recording_active: 'Detener grabación', + dictation_append_label: 'Append dictation to composer', + dictation_append_desc: 'When ON, dictated text is appended to any text already in the composer (with a space if needed). When OFF, dictated text replaces the composer content.', voice_mode_toggle: 'Modo de voz', voice_mode_toggle_active: 'Salir del modo de voz', subagent_children: 'Sesiones de subagentes', @@ -10269,6 +10279,8 @@ const LOCALES = { voice_dictate: 'Diktieren', voice_dictate_active: 'Stop dictation', voice_recording_active: 'Aufnahme stoppen', + dictation_append_label: 'Append dictation to composer', + dictation_append_desc: 'When ON, dictated text is appended to any text already in the composer (with a space if needed). When OFF, dictated text replaces the composer content.', voice_mode_toggle: 'Sprachmodus', voice_mode_toggle_active: 'Sprachmodus verlassen', subagent_children: 'Subagenten-Sitzungen', @@ -11935,6 +11947,8 @@ const LOCALES = { voice_dictate: '听写', voice_dictate_active: '停止听写', voice_recording_active: '停止录音', + dictation_append_label: '将听写追加到输入框', + dictation_append_desc: '启用时,听写文本会追加到输入框已有内容之后(必要时插入空格)。关闭时,听写文本会替换输入框内容。', voice_mode_toggle: '语音模式', voice_mode_toggle_active: '退出语音模式', subagent_children: '子代理会话', @@ -11972,6 +11986,8 @@ const LOCALES = { voice_dictate: '聽寫', voice_dictate_active: '停止聽寫', voice_recording_active: '停止錄音', + dictation_append_label: '將聽寫附加到輸入框', + dictation_append_desc: '啟用時,聽寫文字會附加到輸入框現有內容之後(必要時插入空格)。關閉時,聽寫文字會取代輸入框內容。', voice_mode_toggle: '語音模式', voice_mode_toggle_active: '離開語音模式', // Turn-based voice mode (#1333) @@ -15208,6 +15224,8 @@ const LOCALES = { voice_dictate: 'Ditar', voice_dictate_active: 'Stop dictation', voice_recording_active: 'Parar gravação', + dictation_append_label: 'Append dictation to composer', + dictation_append_desc: 'When ON, dictated text is appended to any text already in the composer (with a space if needed). When OFF, dictated text replaces the composer content.', voice_mode_toggle: 'Modo de voz', voice_mode_toggle_active: 'Sair do modo de voz', subagent_children: 'Sessões de subagentes', @@ -16948,6 +16966,8 @@ const LOCALES = { voice_dictate: '받아쓰기', voice_dictate_active: 'Stop dictation', voice_recording_active: '녹음 중지', + dictation_append_label: 'Append dictation to composer', + dictation_append_desc: 'When ON, dictated text is appended to any text already in the composer (with a space if needed). When OFF, dictated text replaces the composer content.', voice_mode_toggle: '음성 모드', voice_mode_toggle_active: '음성 모드 종료', subagent_children: '서브에이전트 세션', @@ -16981,6 +17001,8 @@ const LOCALES = { voice_dictate: 'Dicter', voice_dictate_active: 'Arrêter la dictée', voice_recording_active: 'Arrêter l\'enregistrement', + dictation_append_label: 'Ajouter la dictée au composer', + dictation_append_desc: 'Lorsque activé, le texte dicté est ajouté au texte déjà présent dans le composer (avec un espace si nécessaire). Lorsque désactivé, le texte dicté remplace le contenu du composer.', voice_mode_toggle: 'Mode vocal', voice_mode_toggle_active: 'Quitter le mode vocal', voice_listening: 'Écoute\u2026', @@ -20316,6 +20338,8 @@ const LOCALES = { voice_dictate: 'Diktovat', voice_dictate_active: 'Zastavit diktování', voice_error: 'Hlas není v tomto prohlížeči podporován', + dictation_append_label: 'Append dictation to composer', + dictation_append_desc: 'When ON, dictated text is appended to any text already in the composer (with a space if needed). When OFF, dictated text replaces the composer content.', voice_listening: 'Poslech…', voice_mode_active: 'Hlasový režim zapnutý', voice_mode_off: 'Hlasový režim vypnutý', @@ -20384,6 +20408,8 @@ const LOCALES = { voice_dictate: 'Dikte', voice_dictate_active: 'Dikteyi durdur', voice_recording_active: 'Kaydı durdur', + dictation_append_label: 'Dikteyi editöre ekle', + dictation_append_desc: 'AÇIK olduğunda, dikte edilen metin editördeki mevcut metne eklenir (gerekirse bir boşlukla). KAPALI olduğunda, dikte edilen metin editör içeriğinin yerini alır.', voice_mode_toggle: 'Ses modu', voice_mode_toggle_active: 'Ses modundan çık', voice_listening: 'Dinleniyor\u2026', @@ -22109,6 +22135,8 @@ const LOCALES = { voice_dictate: 'Dyktuj', voice_dictate_active: 'Zatrzymaj dyktowanie', voice_recording_active: 'Zatrzymaj nagrywanie', + dictation_append_label: 'Dołączaj dyktowanie do edytora', + dictation_append_desc: 'Gdy włączone, tekst dyktowany jest dołączany do tekstu już znajdującego się w edytorze (ze spacją w razie potrzeby). Gdy wyłączone, tekst dyktowany zastępuje zawartość edytora.', voice_mode_toggle: 'Tryb głosowy', voice_mode_toggle_active: 'Wyjdź z trybu głosowego', // Turn-based voice mode (#1333) @@ -23836,6 +23864,8 @@ const LOCALES = { voice_dictate: 'Đọc chính tả', voice_dictate_active: 'Dừng đọc chính tả', voice_recording_active: 'Dừng ghi âm', + dictation_append_label: 'Append dictation to composer', + dictation_append_desc: 'When ON, dictated text is appended to any text already in the composer (with a space if needed). When OFF, dictated text replaces the composer content.', voice_mode_toggle: 'Chế độ giọng nói', voice_mode_toggle_active: 'Thoát chế độ giọng nói', // Turn-based voice mode (#1333) diff --git a/static/index.html b/static/index.html index c27b9f0b..0ca1bc78 100644 --- a/static/index.html +++ b/static/index.html @@ -1230,6 +1230,13 @@
Record and send the original audio file to the agent instead of converting it to text first. The agent can then transcribe it or process the raw audio (emotion, background noise, custom STT). Like Telegram's voice message behavior.
+
+ +
When ON, dictated text is appended to any text already in the composer (with a space if needed). When OFF, dictated text replaces the composer content.
+
Choose speech engine. Edge TTS uses Microsoft neural voices via the server.