* 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 <kopamedve@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
6 files changed
+134
-12
No files matched your search
@@ -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)
|
||||
|
||||
+62
-9
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1230,6 +1230,13 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_raw_audio">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.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsDictationAppend" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="dictation_append_label">Append dictation to composer</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="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.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsTtsEngine" data-i18n="settings_label_tts_engine">TTS Engine</label><select id="settingsTtsEngine" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option><option value="elevenlabs">ElevenLabs (server)</option><option value="openai">OpenAI TTS (server)</option></select><div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_engine">Choose speech engine. Edge TTS uses Microsoft neural voices via the server.</div></div><div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
|
||||
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
|
||||
@@ -178,9 +178,12 @@ def test_590_transcribing_status_shown_before_fetch():
|
||||
|
||||
def test_590_recording_stops_before_transcribe():
|
||||
"""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]
|
||||
# Anchor on the handler definition (not just the substring "recorder.onstop",
|
||||
# which can collide with comments referencing the handler) and widen the
|
||||
# window so future comment additions don't push the send path out of range.
|
||||
onstop_start = BOOT_JS.find("recorder.onstop=async")
|
||||
assert onstop_start != -1, "recorder.onstop=async handler not found"
|
||||
onstop_body = BOOT_JS[onstop_start:onstop_start + 1200]
|
||||
rec_pos = onstop_body.find("_setRecording(false)")
|
||||
blob_pos = onstop_body.find("_transcribeBlob(")
|
||||
raw_pos = onstop_body.find("_sendRawAudio(")
|
||||
|
||||
@@ -175,3 +175,30 @@ def test_send_raw_audio_honors_explicit_pending_send():
|
||||
end = _BOOT_JS.index("function _commitTranscript", idx)
|
||||
body = _BOOT_JS[idx:end]
|
||||
assert "window._micPendingSend" in body and "send()" in body
|
||||
|
||||
|
||||
def test_commit_transcript_appends_to_live_text_not_stale_snapshot():
|
||||
# Regression: server-STT transcription is async (~1s round-trip). During
|
||||
# that window the user can keep typing OR clear the textarea. The original
|
||||
# implementation appended to `_prefix` (the recording-start snapshot),
|
||||
# which clobbered live edits AND resurrected text the user had cleared.
|
||||
#
|
||||
# Fix: when prefixOverride IS passed (server-STT path), trust live ta.value
|
||||
# unconditionally — even when empty. The fallback to _prefix only applies
|
||||
# when no prefixOverride is given AND ta.value is empty (defensive only —
|
||||
# no current caller hits this branch; browser-SR commits inline in sr.onend).
|
||||
idx = _BOOT_JS.index("function _commitTranscript")
|
||||
end = _BOOT_JS.index("\n function ", idx + 1)
|
||||
body = _BOOT_JS[idx:end]
|
||||
# The append base must be `prefixOverride !== undefined ? ta.value : ...`
|
||||
# so live ta.value (including empty) wins for the server-STT path.
|
||||
assert "prefixOverride !== undefined ? ta.value" in body, (
|
||||
"_commitTranscript must use live ta.value when prefixOverride is set, "
|
||||
"even when empty — otherwise cleared textarea resurrects on resolve "
|
||||
"(Greptile P1 finding on 0239bb4e)"
|
||||
)
|
||||
# Sanity: the fallback branch should still exist for the defensive case.
|
||||
assert "_prefix" in body, (
|
||||
"_commitTranscript must keep _prefix as a fallback for any future "
|
||||
"caller that passes no prefixOverride"
|
||||
)
|
||||
Reference in new issue
Block a user