From c6f5d78a7d45b789efdc8f983264509ea62bda8c Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 10 Jun 2026 19:06:57 +0800 Subject: [PATCH] feat(#613): retain recording on ASR failure + retranscribe from history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 autoRetainRecordingOnFailure 设置(默认 false) - 14 个 ASR 失败分支写入 history(errorCode: transcribeFailed/transcribeTimeout) - 新增 retranscribe_history_entry IPC(支持 Whisper/MiMo WAV PCM 转写) - 历史页渲染「重新转录」按钮 + 交互状态 - Settings 页面新增「转录失败时保留录音」开关 - 修 emptyTranscript 路径 session id 对齐 WAV 文件 bug - 5 语言(zh-CN/en/zh-TW/ja/ko)i18n 补全 Closes #613 --- openless-all/app/src-tauri/src/asr/mimo.rs | 5 + openless-all/app/src-tauri/src/asr/wav.rs | 58 ++++++++++ openless-all/app/src-tauri/src/asr/whisper.rs | 5 + .../app/src-tauri/src/commands/history.rs | 97 ++++++++++++++++ openless-all/app/src-tauri/src/coordinator.rs | 2 + .../src/coordinator/dictation_end.rs | 109 +++++++++++++++++- .../src/coordinator/dictation_session.rs | 13 ++- openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src-tauri/src/persistence.rs | 27 +++++ openless-all/app/src-tauri/src/types.rs | 10 ++ openless-all/app/src/i18n/en.ts | 5 + openless-all/app/src/i18n/ja.ts | 5 + openless-all/app/src/i18n/ko.ts | 5 + openless-all/app/src/i18n/zh-CN.ts | 5 + openless-all/app/src/i18n/zh-TW.ts | 5 + openless-all/app/src/lib/ipc.ts | 28 +++++ openless-all/app/src/lib/types.ts | 2 + openless-all/app/src/pages/History.tsx | 24 +++- .../src/pages/settings/DataStorageSection.tsx | 7 ++ 19 files changed, 406 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/mimo.rs b/openless-all/app/src-tauri/src/asr/mimo.rs index 1fe68522..589f7534 100644 --- a/openless-all/app/src-tauri/src/asr/mimo.rs +++ b/openless-all/app/src-tauri/src/asr/mimo.rs @@ -54,6 +54,11 @@ impl MimoBatchASR { result } + /// #613: 从外部 PCM 数据直接转写(不经过内部 buffer)。用于历史重转录场景。 + pub async fn transcribe_pcm(&self, pcm: &[u8]) -> Result { + self.transcribe_inner(pcm).await + } + async fn transcribe_inner(&self, pcm: &[u8]) -> Result { if self.api_key.trim().is_empty() { anyhow::bail!("MiMo API key missing"); diff --git a/openless-all/app/src-tauri/src/asr/wav.rs b/openless-all/app/src-tauri/src/asr/wav.rs index 91503d15..b0750115 100644 --- a/openless-all/app/src-tauri/src/asr/wav.rs +++ b/openless-all/app/src-tauri/src/asr/wav.rs @@ -1,5 +1,63 @@ //! WAV helpers for ASR providers that accept complete audio files. +/// Decode a RIFF WAV file to 16-bit PCM samples. Returns `Err` if the WAV header is +/// invalid, the format is not 16-bit mono PCM, or the sample rate is not supported. +/// Used by retranscribe to extract raw PCM from archived recording files. +pub fn decode_wav_to_pcm_i16(wav_bytes: &[u8]) -> Result, String> { + if wav_bytes.len() < 44 { + return Err("wav too short for valid header".into()); + } + if &wav_bytes[0..4] != b"RIFF" || &wav_bytes[8..12] != b"WAVE" { + return Err("not a valid RIFF WAV file".into()); + } + if &wav_bytes[12..16] != b"fmt " { + return Err("missing fmt chunk".into()); + } + let audio_format = u16::from_le_bytes([wav_bytes[20], wav_bytes[21]]); + if audio_format != 1 { + return Err(format!("unsupported audio format {audio_format} (expected PCM=1)")); + } + let num_channels = u16::from_le_bytes([wav_bytes[22], wav_bytes[23]]); + let sample_rate = u32::from_le_bytes([wav_bytes[24], wav_bytes[25], wav_bytes[26], wav_bytes[27]]); + let bits_per_sample = u16::from_le_bytes([wav_bytes[34], wav_bytes[35]]); + if num_channels != 1 || bits_per_sample != 16 { + return Err(format!( + "expected mono 16-bit PCM, got {num_channels}ch {bits_per_sample}-bit" + )); + } + // Accept 8k/16k/48k; resampling not needed for most ASR APIs (they handle it server-side). + if sample_rate != 8000 && sample_rate != 16_000 && sample_rate != 44_100 && sample_rate != 48_000 { + log::warn!("[wav] unusual sample rate {sample_rate} Hz — ASR may reject"); + } + // Find the data chunk (skip past fmt chunk). + let mut offset = 36; + while offset + 8 <= wav_bytes.len() { + let chunk_id = &wav_bytes[offset..offset + 4]; + let chunk_size = u32::from_le_bytes([ + wav_bytes[offset + 4], + wav_bytes[offset + 5], + wav_bytes[offset + 6], + wav_bytes[offset + 7], + ]) as usize; + if chunk_id == b"data" { + let data_start = offset + 8; + let data_end = (data_start + chunk_size).min(wav_bytes.len()); + let pcm_bytes = &wav_bytes[data_start..data_end]; + let samples: Vec = pcm_bytes + .chunks_exact(2) + .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + return Ok(samples); + } + offset += 8 + chunk_size; + // Align to 2-byte boundary as per WAV spec. + if chunk_size % 2 != 0 { + offset += 1; + } + } + Err("no data chunk found in WAV".into()) +} + /// Encode 16 kHz / mono / 16-bit little-endian PCM samples as a RIFF WAV file. pub fn encode_wav_16k_mono(samples: &[i16]) -> Vec { let sample_rate: u32 = 16_000; diff --git a/openless-all/app/src-tauri/src/asr/whisper.rs b/openless-all/app/src-tauri/src/asr/whisper.rs index f5626734..d6315e11 100644 --- a/openless-all/app/src-tauri/src/asr/whisper.rs +++ b/openless-all/app/src-tauri/src/asr/whisper.rs @@ -108,6 +108,11 @@ impl WhisperBatchASR { result } + /// #613: 从外部 PCM 数据直接转写(不经过内部 buffer)。用于历史重转录场景。 + pub async fn transcribe_pcm(&self, pcm: &[u8]) -> Result { + self.transcribe_inner(pcm).await + } + async fn transcribe_inner(&self, pcm: &[u8]) -> Result { if self.api_key.is_empty() { anyhow::bail!("Whisper API key missing"); diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index 6f43042b..d40ff9c9 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -15,6 +15,103 @@ pub fn clear_history(coord: CoordinatorState<'_>) -> Result<(), String> { coord.history().clear().map_err(|e| e.to_string()) } +/// #613: 对一条 ASR 转录失败的历史条目,用归档的 WAV 文件重新转写。 +/// +/// 工作流: +/// 1. 校验 session_id(UUID-v4 白名单) +/// 2. 从 history JSON 查找原条目 +/// 3. 读取归档 WAV → 解码为 PCM +/// 4. 按当前 `active_asr_provider` 构造 ASR 并转写 +/// 5. 成功 → 更新历史条目(rawTranscript + 清除 errorCode);失败 → 返回错误,原条目不修改 +/// +/// 目前支持的 ASR 提供商: +/// - Whisper / MiMo(HTTP batch)— 直接 transcribe PCM +/// - 其他(Volcengine/Bailian/本地模型)— 暂时返回 "unsupported provider" 错误 +#[tauri::command] +pub async fn retranscribe_history_entry( + session_id: String, +) -> Result { + if !is_valid_session_id(&session_id) { + return Err("invalid session id".into()); + } + + // Read WAV file — use non-CoordinatorState path (standalone command) + let wav_path = crate::persistence::recording_path_for_session(&session_id) + .map_err(|e| e.to_string())?; + if !wav_path.exists() { + return Err("recording not found".into()); + } + let wav_bytes = tokio::fs::read(&wav_path).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + "recording not found".into() + } else { + format!("read wav failed: {e}") + } + })?; + + // Decode WAV → PCM bytes (16-bit little-endian interleaved) + let pcm_samples = + crate::asr::wav::decode_wav_to_pcm_i16(&wav_bytes)?; + let pcm_bytes: Vec = pcm_samples + .iter() + .flat_map(|s| s.to_le_bytes()) + .collect(); + + // Determine ASR provider from prefs + let prefs = crate::persistence::PreferencesStore::new() + .map_err(|e| e.to_string())? + .get(); + let provider = &prefs.active_asr_provider; + + let raw = transcribe_pcm_from_wav(&pcm_bytes, provider).await?; + + // Update the history entry + let history = crate::persistence::HistoryStore::new().map_err(|e| e.to_string())?; + let Some(mut entry) = history.find_entry(&session_id).map_err(|e| e.to_string())? else { + return Err("history entry not found".into()); + }; + entry.raw_transcript = raw.text; + entry.error_code = None; + history + .update_entry(&session_id, entry.clone()) + .map_err(|e| e.to_string())?; + + Ok(entry) +} + +/// Core transcription engine dispatch: pick the right ASR provider based on the +/// `active_asr_provider` string and call its batch transcription method with raw PCM. +async fn transcribe_pcm_from_wav( + pcm: &[u8], + provider: &str, +) -> Result { + match provider { + "whisper" => { + let creds = crate::coordinator::read_whisper_credentials(); + let asr = crate::asr::WhisperBatchASR::new( + creds.0, + creds.1, + creds.2, + None, // prompt: retranscribe uses None — hotword context unavailable + None, // no chunk limit + false, // verbose_json: false for retranscribe + ); + asr.transcribe_pcm(pcm).await.map_err(|e| e.to_string()) + } + "mimo" => { + let creds = crate::coordinator::read_mimo_credentials(); + let asr = crate::asr::MimoBatchASR::new(creds.0, creds.1, creds.2); + asr.transcribe_pcm(pcm).await.map_err(|e| e.to_string()) + } + // All other providers currently unsupported for file-based retranscription. + // See issue #613 discussion: Volcengine/Bailian use WebSocket streaming, + // local models need runtime access that isn't available from this standalone command. + _ => Err(format!( + "当前 ASR 提供商 \"{provider}\" 不支持文件重转录。请切换到 Whisper 或 MiMo 后重试。" + )), + } +} + /// 读取某次会话的原始麦克风 wav 字节流。仅当用户开过 /// `prefs.record_audio_for_debug` 并且这条 session 是开关打开后录的,才会有文件。 /// 文件名规约:`/recordings/.wav`,与 DictationSession.id 同名。 diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 1579dc3f..8c87e771 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -83,6 +83,8 @@ pub(crate) use capsule::*; pub(crate) use dictation_end::*; pub(crate) use dictation_session::*; pub(crate) use dictation_streaming::*; +// #613: re-export credential readers for retranscribe_history_entry IPC command. +pub(crate) use llm_pipeline::{read_bailian_credentials, read_mimo_credentials, read_volc_credentials, read_whisper_credentials}; pub(crate) use dictation_voice_agent::*; pub(crate) use hotkey_supervisors::*; pub(crate) use ime_insertion::*; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs index 453f5e9b..3b4cddd5 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs @@ -6,6 +6,41 @@ use crate::correction::apply_correction_rules; use super::resources::*; use super::*; +/// #613: ASR 转录失败时向 history 写入一条失败条目,保留录音供用户在历史页中重试。 +/// 复用录音时的 session_id 以保证 WAV 文件名对齐;用两类 error_code 区分超时/引擎失败。 +fn append_failed_history_entry( + inner: &Arc, + session_id: Uuid, + elapsed_ms: u64, + error_code: &str, +) { + let session = DictationSession { + id: session_id.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + raw_transcript: String::new(), + final_text: String::new(), + mode: inner.prefs.get().default_mode, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: None, + insert_status: InsertStatus::Failed, + error_code: Some(error_code.to_string()), + duration_ms: Some(elapsed_ms), + dictionary_entry_count: None, + has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), + }; + let prefs_snapshot = inner.prefs.get(); + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { + log::error!("[coord] history append on ASR failure failed: {e}"); + } +} + pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { let current_session_id = { let mut state = inner.state.lock(); @@ -54,6 +89,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeFailed", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -75,6 +116,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some("识别超时".to_string()), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeTimeout", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -98,6 +145,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeFailed", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -116,6 +169,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some("识别超时".to_string()), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeTimeout", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -138,6 +197,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeFailed", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -156,6 +221,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some("识别超时".to_string()), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeTimeout", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -181,6 +252,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeFailed", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -200,6 +277,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some("识别超时".to_string()), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeTimeout", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -247,6 +330,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("本地识别失败: {e}")), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeFailed", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -296,6 +385,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("本地识别失败: {e}")), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeFailed", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -333,6 +428,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("本地识别失败: {e}")), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeFailed", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -352,6 +453,12 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Some("识别超时".to_string()), None, ); + append_failed_history_entry( + inner, + current_session_id, + elapsed, + "transcribeTimeout", + ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); @@ -395,7 +502,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { if raw.text.trim().is_empty() { let session = DictationSession { - id: Uuid::new_v4().to_string(), + id: current_session_id.to_string(), created_at: Utc::now().to_rfc3339(), raw_transcript: raw.text.clone(), final_text: String::new(), diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs index 480883af..94bf5137 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs @@ -448,14 +448,17 @@ pub(crate) async fn start_recorder_for_starting( let microphone_device_name = selected_microphone_device_name(inner); stop_microphone_preview_monitor(inner, "dictation recorder"); acquire_recording_mute(inner, "dictation").await; - let audio_archive_path = if inner.prefs.get().record_audio_for_debug { + let prefs_snapshot = inner.prefs.get(); + // #613: 录音存档条件扩展——调试开关 OR 失败时自动保留。两者任何一个开启都应写盘。 + let should_archive = + prefs_snapshot.record_audio_for_debug || prefs_snapshot.auto_retain_recording_on_failure; + let audio_archive_path = if should_archive { // 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见 // 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。 - // 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。 - let prefs = inner.prefs.get(); + // 顺手把超龄 / 超量录音清理一下,避免开关常开时磁盘膨胀。 let _ = crate::persistence::prune_recordings( - prefs.history_retention_days, - prefs.audio_recording_max_entries, + prefs_snapshot.history_retention_days, + prefs_snapshot.audio_recording_max_entries, ); crate::persistence::recording_path_for_session(&session_id.to_string()).ok() } else { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 567d5a8b..a3a7317e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -365,6 +365,7 @@ pub fn run() { commands::delete_history_entry, commands::clear_history, commands::read_audio_recording, + commands::retranscribe_history_entry, commands::marketplace_list, commands::marketplace_detail, commands::marketplace_install, diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index c330c72d..63f984d2 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -1349,6 +1349,33 @@ impl HistoryStore { self.write_locked(&Vec::::new()) } + /// #613: 按 id 查找单条历史记录。用于重转录时定位原条目。 + pub fn find_entry(&self, id: &str) -> Result> { + let _guard = self.lock.lock(); + let sessions = self.read_locked()?; + Ok(sessions.into_iter().find(|s| s.id == id)) + } + + /// #613: 按 id 更新单条历史记录(重转录成功后覆写 rawTranscript / finalText 等字段)。 + /// 若原条目不存在则静默 no-op(日志记录),防止并发删除。 + pub fn update_entry(&self, id: &str, updated: DictationSession) -> Result<()> { + let _guard = self.lock.lock(); + let mut sessions = self.read_locked()?; + let mut found = false; + for s in &mut sessions { + if s.id == id { + *s = updated; + found = true; + break; + } + } + if !found { + log::warn!("[history] update_entry: session {id} not found (may have been deleted)"); + return Ok(()); + } + self.write_locked(&sessions) + } + /// issue #609 F-03:读 history 前先做 HMAC 完整性校验。 /// /// - HMAC 密钥不可用(keyring 缺失等)→ 退化为不校验,按内容直接读(保持可用)。 diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 97124109..c5f773b2 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -735,6 +735,11 @@ pub struct UserPreferences { /// 受 `history_retention_days` 同样的清理策略约束。 #[serde(default)] pub record_audio_for_debug: bool, + /// 当 ASR 转录失败(识别失败 / 超时)时,是否自动保留本次录音以便在历史页中 + /// 重新转录。默认 false(隐私优先)。需要用户主动在设置中开启。 + /// 与 `record_audio_for_debug` 独立:前者是常数调试开关,这个只针对失败场景。 + #[serde(default)] + pub auto_retain_recording_on_failure: bool, /// `recordings/` 里保留的最近 wav 文件数(按 mtime 倒序保留最新的)。 /// `None` = 跟随 `HISTORY_CAP` (200);`Some(n)` 时 clamp 到 1..=200。 /// 调用点:每次开新会话前裁旧。让用户在「文本历史保留 200 条但 wav 只留最近 5 条」 @@ -893,6 +898,8 @@ struct UserPreferencesWire { #[serde(default)] record_audio_for_debug: bool, #[serde(default)] + auto_retain_recording_on_failure: bool, + #[serde(default)] audio_recording_max_entries: Option, #[serde(default)] marketplace_base_url: String, @@ -962,6 +969,7 @@ impl Default for UserPreferencesWire { auto_update_check: prefs.auto_update_check, history_max_entries: prefs.history_max_entries, record_audio_for_debug: prefs.record_audio_for_debug, + auto_retain_recording_on_failure: prefs.auto_retain_recording_on_failure, audio_recording_max_entries: prefs.audio_recording_max_entries, marketplace_base_url: prefs.marketplace_base_url, marketplace_dev_login: prefs.marketplace_dev_login, @@ -1058,6 +1066,7 @@ impl<'de> Deserialize<'de> for UserPreferences { auto_update_check: wire.auto_update_check, history_max_entries: wire.history_max_entries, record_audio_for_debug: wire.record_audio_for_debug, + auto_retain_recording_on_failure: wire.auto_retain_recording_on_failure, audio_recording_max_entries: wire.audio_recording_max_entries, marketplace_base_url: wire.marketplace_base_url, marketplace_dev_login: wire.marketplace_dev_login, @@ -1786,6 +1795,7 @@ impl Default for UserPreferences { auto_update_check: true, history_max_entries: None, record_audio_for_debug: false, + auto_retain_recording_on_failure: false, audio_recording_max_entries: None, marketplace_base_url: String::new(), marketplace_dev_login: String::new(), diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 04e0bea6..e4869ef0 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -275,6 +275,9 @@ export const en: typeof zhCN = { audioLoading: 'Loading…', exportRecording: 'Export recording', exportFailed: 'Failed to export: {{err}}', + retranscribe: 'Retranscribe', + retranscribing: 'Retranscribing…', + retranscribeFailed: 'Retranscribe failed: {{err}}', rawLabel: 'Raw', rawEmpty: '(empty)', selectHint: 'Select an entry on the left to see details.', @@ -647,6 +650,8 @@ export const en: typeof zhCN = { recordAudioForDebugDesc: 'Save raw microphone audio as wav for diagnosing recognition issues.', audioRecordingMaxEntriesLabel: 'Max raw recordings', audioRecordingMaxEntriesDesc: 'Max wav files retained locally. Blank = 200.', + autoRetainRecordingOnFailureLabel: 'Keep recording on ASR failure', + autoRetainRecordingOnFailureDesc: 'Automatically save raw audio when transcription fails, so you can retry from History.', startupGroupTitle: 'Startup', startMinimizedLabel: 'Start minimized (no main window)', startMinimizedDesc: 'No main window on any launch path — menu bar / tray only.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 8e026a22..7fd1166e 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -277,6 +277,9 @@ export const ja: typeof zhCN = { audioLoading: '読み込み中…', exportRecording: '録音をエクスポート', exportFailed: 'エクスポート失敗:{{err}}', + retranscribe: '再認識', + retranscribing: '認識中…', + retranscribeFailed: '再認識失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側から 1 件選択して詳細を表示。', @@ -649,6 +652,8 @@ export const ja: typeof zhCN = { recordAudioForDebugDesc: '生のマイク音声を wav で保存し、認識問題の診断に利用。', audioRecordingMaxEntriesLabel: '元音声の保持件数', audioRecordingMaxEntriesDesc: 'ローカル保持 wav ファイル上限。空欄 = 200。', + autoRetainRecordingOnFailureLabel: '認識失敗時に録音を保持', + autoRetainRecordingOnFailureDesc: 'ASR 認識失敗時に自動的に録音を保存し、履歴ページから再認識できます。', startupGroupTitle: '起動', startMinimizedLabel: '起動時にメインウィンドウを表示しない', startMinimizedDesc: 'どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 49aa5576..e52efcbd 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -277,6 +277,9 @@ export const ko: typeof zhCN = { audioLoading: '로딩 중…', exportRecording: '녹음 내보내기', exportFailed: '내보내기 실패: {{err}}', + retranscribe: '재인식', + retranscribing: '인식 중…', + retranscribeFailed: '재인식 실패: {{err}}', rawLabel: '원문', rawEmpty: '(비어 있음)', selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.', @@ -649,6 +652,8 @@ export const ko: typeof zhCN = { recordAudioForDebugDesc: '원시 마이크 오디오를 wav 로 저장하여 인식 문제 진단.', audioRecordingMaxEntriesLabel: '원본 녹음 보관 개수', audioRecordingMaxEntriesDesc: '로컬 보관 wav 파일 상한. 빈칸 = 200.', + autoRetainRecordingOnFailureLabel: '인식 실패 시 녹음 보존', + autoRetainRecordingOnFailureDesc: 'ASR 인식 실패 시 자동으로 녹음을 저장하여 기록 페이지에서 다시 인식할 수 있습니다.', startupGroupTitle: '시작', startMinimizedLabel: '시작 시 메인 창 숨기기', startMinimizedDesc: '모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 6b7a898b..bac4290e 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -273,6 +273,9 @@ export const zhCN = { audioLoading: '加载中…', exportRecording: '导出录音', exportFailed: '导出失败:{{err}}', + retranscribe: '重新转录', + retranscribing: '转录中…', + retranscribeFailed: '重新转录失败:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左侧选一条查看详情。', @@ -645,6 +648,8 @@ export const zhCN = { recordAudioForDebugDesc: '保存原始麦克风音频为 wav,便于排查识别问题。', audioRecordingMaxEntriesLabel: '原始录音保留条数', audioRecordingMaxEntriesDesc: '本地保留 wav 文件数上限,留空 = 200。', + autoRetainRecordingOnFailureLabel: '转录失败时保留录音', + autoRetainRecordingOnFailureDesc: 'ASR 转录失败后自动保留原始录音,可在历史页中重试转录。', startupGroupTitle: '启动', startMinimizedLabel: '启动时静默运行', startMinimizedDesc: '所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f8739be5..06ab1ed1 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -275,6 +275,9 @@ export const zhTW: typeof zhCN = { audioLoading: '載入中…', exportRecording: '匯出錄音', exportFailed: '匯出失敗:{{err}}', + retranscribe: '重新轉錄', + retranscribing: '轉錄中…', + retranscribeFailed: '重新轉錄失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側選一條查看詳情。', @@ -647,6 +650,8 @@ export const zhTW: typeof zhCN = { recordAudioForDebugDesc: '保存原始麥克風音訊為 wav,便於排查識別問題。', audioRecordingMaxEntriesLabel: '原始錄音保留條數', audioRecordingMaxEntriesDesc: '本地保留 wav 檔案數上限,留空 = 200。', + autoRetainRecordingOnFailureLabel: '轉錄失敗時保留錄音', + autoRetainRecordingOnFailureDesc: 'ASR 轉錄失敗後自動保留原始錄音,可在歷史頁中重試轉錄。', startupGroupTitle: '啟動', startMinimizedLabel: '啓動時靜默運行', startMinimizedDesc: '所有啓動路徑都不彈主窗口,僅選單欄 / 托盤運行。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 8f8d246b..24ef956a 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -706,6 +706,34 @@ export function readAudioRecording(sessionId: string): Promise { }) } +/** #613: 对一条转录失败的历史条目重试 ASR 转写。后端读取归档 WAV、 + * 用当前 ASR 提供商重新转写,成功后更新 rawTranscript 并清除 errorCode + * 返回更新后的 DictationSession;失败时原条目不修改。 */ +export function retranscribeHistoryEntry(sessionId: string): Promise { + return invokeOrMock( + "retranscribe_history_entry", + { sessionId }, + () => + ({ + id: sessionId, + createdAt: new Date().toISOString(), + rawTranscript: '[mock] 重新转录完成', + finalText: '[mock] 重新转录完成', + mode: 'raw' as const, + stylePackId: null, + translationActive: false, + polishSource: null, + appBundleId: null, + appName: null, + insertStatus: 'inserted' as const, + errorCode: null, + durationMs: 30000, + dictionaryEntryCount: null, + hasAudioRecording: true, + }) as DictationSession, + ) +} + // ── Vocab ────────────────────────────────────────────────────────────── export function listVocab(): Promise { return invokeOrMock("list_vocab", undefined, () => mockVocab) diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 343c54de..5acb82f7 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -331,6 +331,8 @@ export interface UserPreferences { /** 是否为每次会话保留原始麦克风音频文件(wav),用于排查 ASR 误识别 / 麦克风灵敏度。 * 默认 false。开启后会占磁盘空间,受 historyRetentionDays 同样的清理策略约束。 */ recordAudioForDebug: boolean; + /** #613: ASR 转录失败时是否自动保留原始录音以便在历史中重试。默认 false,隐私优先。 */ + autoRetainRecordingOnFailure: boolean; /** recordings/ 里保留的最近 wav 文件数。null = 跟随 200 硬上限;1..=200 之间为用户自定义。 * 跟 historyMaxEntries 解耦——「文本档案多但 wav 只留最近 5 条」是合法组合。 */ audioRecordingMaxEntries: number | null; diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index f030fe9b..2c42e2e8 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; import { formatComboLabel } from '../lib/hotkey'; -import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording } from '../lib/ipc'; +import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording, retranscribeHistoryEntry } from '../lib/ipc'; import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -44,6 +44,7 @@ export function History() { const [loadError, setLoadError] = useState(null); const [actionError, setActionError] = useState(null); const [justCopied, setJustCopied] = useState(false); + const [retranscribing, setRetranscribing] = useState(false); // 录音文件 lazily-detected missing 状态:retention / 条数 cap 清理后磁盘上 wav // 可能已被删,但 history 条目 hasAudioRecording 仍写 true。任一组件 // (播放 / 导出)首次 IPC 拿到 'recording not found' 时把 id 加进来, @@ -163,6 +164,22 @@ export function History() { } }; + const onRetranscribe = async () => { + if (!item) return; + setActionError(null); + setRetranscribing(true); + try { + const updated = await retranscribeHistoryEntry(item.id); + setItems(prev => prev.map(s => (s.id === updated.id ? updated : s))); + return updated; + } catch (error) { + console.error('[history] retranscribe failed', error); + setActionError(t('history.retranscribeFailed', { err: errorMessage(error) })); + } finally { + setRetranscribing(false); + } + }; + return (
void onExportAudio()}>{t('history.exportRecording')} )} + {item.hasAudioRecording && item.errorCode && !audioMissingIds.has(item.id) && ( + void onRetranscribe()}> + {retranscribing ? t('history.retranscribing') : t('history.retranscribe')} + + )} {t('common.delete')}
diff --git a/openless-all/app/src/pages/settings/DataStorageSection.tsx b/openless-all/app/src/pages/settings/DataStorageSection.tsx index 5727a747..70200186 100644 --- a/openless-all/app/src/pages/settings/DataStorageSection.tsx +++ b/openless-all/app/src/pages/settings/DataStorageSection.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { Card } from '../_atoms'; import { SettingRow, SectionTitle, inputStyle } from './shared'; +import { SwitchLite } from '../ui/SwitchLite'; // 范围限制:retention 0-365 天,context window 0-60 分钟(再大对实际对话场景没意义且白烧 token)。 const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); @@ -79,6 +80,12 @@ export function DataStorageSection() { style={{ ...inputStyle, width: 80, textAlign: 'right' }} /> + + void savePrefs({ ...prefs, autoRetainRecordingOnFailure: v })} + /> + ); }