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.