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 @@